Compare commits

...

811 Commits

Author SHA1 Message Date
github-actions[bot] c87e6d2fc2 chore: bump version to 4.1.0 [skip ci] 2026-08-29 11:16:19 +00:00
Maurice e6ccaf89de Revert "chore: bump version to 4.1.0 [skip ci]"
This reverts commit 88ebf89ab3.
2026-08-29 13:15:22 +02:00
Maurice 061a3d91a0 fix(notices): keep the release modal up across the whole 4.x line
The 4.0.0 notice was scoped to a 4.0.x window on the assumption that each
release would bring its own entry. 4.1.0 shipped without one, so the modal
switched itself off and nothing took its place: the thank-you notice it
replaced is retired below 4.0.0, leaving a 4.1 install with no notice at all.

Hold it open to the 5.0.0 boundary instead. What it introduces is the 4.x
line, which is still what somebody arriving on 4.1 or 4.2 is meeting for the
first time, and a later major gets its own entry the way this one did.
2026-08-29 13:14:30 +02:00
github-actions[bot] 88ebf89ab3 chore: bump version to 4.1.0 [skip ci] 2026-08-29 10:59:15 +00:00
Maurice 9162f5d7ac fix(maps): stop the day route drawing drives to airports you flew from
A flight leaving overnight contributes only its departure airport on the day
it leaves, and the evening hotel bookend was attaching to whatever point sat
at the day's edge. So the map drew a straight line from the airport you took
off from to the hotel you were checking into.

The same blind spot on the run builder produced the long rays: a day's order
is a bare wall clock, so an inbound long-haul whose departure clock reads late
sorts after the day's local stops, and its far departure airport got appended
to that run. OSRM answers such a pair with NoRoute and the fallback straightens
the whole chunk, taking the genuine local drive with it.

Both now ask whether the pair could be driven at all. A flight, train, ferry,
cruise or coach carries you out of the day's geography, so its departure point
has no drive back to tonight's hotel and its arrival point had no drive from
this morning's. A hire car is the opposite and stays out of that set: you keep
driving it, so a multi-day rental's pickup and drop-off points keep their hotel
legs. Alongside it, any leg touching a booking endpoint has to be within
MAX_DRIVE_KM, which catches the mis-sorted edges the role alone cannot see.

The sidebar gets both guards so its hotel connectors stay in step with the
drawn route; the mobile map and timeline read the same hook.
2026-08-29 12:23:30 +02:00
Maurice 3c865c9605 docs(wiki): document plugins:use
The scope that turns on third-party tool execution was the one missing from the
scope reference, and the count beside it was stale. It says what the scope does
not do, since the boundary is the admin's mcp:tools grant rather than anything on
the token.
2026-08-29 12:22:59 +02:00
Maurice a8a05ace40 fix(plugins): close the gaps between what a tool schema advertises and what it enforces
`.meta()` advertises every node of the declaration; `nodeToZod` only walks the
ones it can type. Everything below follows from that gap, so the keyword
allowlist moves into `sanitiseNode`, which is the one walker that sees every
node.

A `pattern` was compiled straight into a RegExp and run on the main thread
against caller input, with no timeout and nothing to abort it: `^(a+)+$` against
33 characters stops the whole server, not just MCP. That is an escape from the
supervised child into the host event loop, and it does not take malice, since a
hand-written email pattern is the classic accident. Quantified groups that repeat
or alternate inside are refused, and the source is capped.

`mcp:tools` counted as a side effect, so `readOnlyHint` was structurally
unreachable: holding that grant is what lets a plugin advertise a tool at all.
Every plugin tool was therefore advertised as potentially destructive and every
client's auto-approval was off. It is a host entry point like `hook:*`, and is
classified as one.

Property names, `default` and `examples` were the strings that never met the
sanitiser. A key may carry newlines, so `\n## SYSTEM\n...` in one defeated the
newline collapse everything else goes through. Names are identifiers now; sample
values keep their own keys but have their strings bounded.

Also refused at parse time, which is what the rest of the file already does: an
object `enum` member carrying `$ref` past the deep scan, a non-boolean
`additionalProperties` advertised and checked by nothing, `enum` beside
`properties` skipping the children, a root `enum`/`const`/`nullable` producing a
non-object schema the SDK cannot read, and a draft-4 `exclusiveMinimum: true`
dropped from the check while staying in the advertisement.

`required` used `in`, so a field named `toString` read as present on every call.
Nodes are built with a null prototype, and the three names Zod cannot round-trip
are refused rather than silently lost.

A result block that had to be shortened got no marker and no log line, which
reads to the model like the whole answer. It is counted now, like a dropped one,
and `isError` survives every envelope path.

The security test file carried two raw NUL bytes, so git, GitHub and ripgrep all
treated it as binary: no diff, no blame, no review. Escaped, same runtime value.
2026-08-29 12:22:59 +02:00
Renzo Beux 937e32924d fix(plugins): close six gaps in the MCP tool boundary
Found in review of the branch. Each one is a case where the host told the
model or the admin something it did not enforce.

Advertised schemas are now enforced. buildToolInputSchema built a loose
object that checked only which keys were present, so a tool advertising
an integer, an enum and additionalProperties: false accepted a string, an
unlisted value and an extra property. .meta() still advertises the
declaration verbatim, and the schema underneath it is now built from that
same declaration, so the two cannot disagree. Anything the builder cannot
enforce (oneOf, unknown formats, non-primitive enum members) is refused by
parseManifest rather than advertised, and the failure lands at install
time where an admin sees it.

Writing the test for that turned up a live bug: z.custom() cannot be
represented in JSON Schema, so building enums that way would have thrown
inside tools/list for the whole session. Enums and consts are real literal
types now.

readOnlyHint classification fails closed. The side-effect list spelled out
db:write: and missed db:create:trips entirely, letting a plugin that can
create trips advertise a tool as read-only and idempotent, which is what
clients gate auto-approval on. It is an allowlist of read-only grants now,
and mcp-tool-annotations.test.ts walks the whole catalog so a new
permission fails a test rather than producing a wrong hint.

The result cap covers the whole result. It bounded each text block, so a
hundred blocks cost a hundred times the limit: 6.5M characters in a probe,
now 65K. Large objects are serialised with a budget instead of stringified
whole and then sliced, and the block count is capped.

The admin tool list was dead code. The registry preview carries only
widget and tripPage, so the section added for it never rendered, and the
UI hid the description behind the title when both existed. The preview
carries sanitised tools now (previewManifest is exported and tested, which
is how the gap survived), and both fields render, description always.

plugins:use is out of the DCR default. Adding it to ALL_SCOPES meant a
registration that names no scopes got it and had it pre-selected at
consent. Defaults come from DEFAULT_CLIENT_SCOPES now; asking for it by
name still works.

The public ManifestCapabilities gained mcpTools, so a typed manifest can
express what the runtime already validates.

MCPANN-001..005, MCPENF-001..010, MCPPREV-001..005, MCPTOOLS-020..022,
SDKP-015b/c.
2026-08-29 12:22:59 +02:00
Renzo Beux eb7d595203 feat(admin): show the MCP tools a plugin will advertise
The manifest declaration only becomes an admin-visible control if the
admin can actually read it. Nothing rendered capabilities.mcpTools, so
approving mcp:tools meant approving text you would first meet inside a
chat.

The pre-install dialog now lists the tool names and descriptions, beside
the existing access and egress sections, and the grant gets an
at-a-glance chip like every other capability.

Desktop and mobile both, since the panels are parallel copies.
2026-08-29 12:22:59 +02:00
Renzo Beux 957cdffa7b test(mcp): end-to-end cover for plugin-contributed tools
Integration rather than unit, because what is being proved is that
nothing reaches the global exception filters. registerTools is called
outside the transport's try block, and mcp-transport.service.ts states
that every response there is written by hand and nothing may be thrown,
so a registry-level unit test cannot show it.

MCP-P15: a contributed tool that takes a built-in's name is skipped, the
built-in is the one still listed, and the session serves normally.
MCP-P16: a source that throws costs its tools and nothing else, where it
would previously have been a 500 on every /mcp initialize.
MCP-P17: a contributed tool call writes the same mcp.tool_call audit row
as a built-in, which is the whole reason the source hangs off
McpAttachOptions rather than the host calling registerTool itself.

MCP-P18 came out of writing the others: the first draft of MCP-P17 minted
a trips:read token and got no audit row, because the declarative access
marker had correctly hidden the tool. That is worth pinning.

mintOauthToken takes scopes now, and rpcResult parses the SSE frame the
transport answers with.
2026-08-29 12:22:59 +02:00
Renzo Beux 65a6a46d70 fix(plugins): invalidate MCP sessions on lifecycle and grant changes
The advertised tool surface is frozen at initialize, so anything that
changes which plugin tools exist has to end the sessions built before it.
tools/list_changed is not an alternative: no event store is configured,
and the surface would not be re-read anyway.

Unconditional, no diffing. The surface is per-session, since it depends
on the caller's scopes and the plugin's grants, so there is no ctx-free
set to compare against.

Five routes, mirroring what admin.controller.ts already does for addons:
activate, deactivate, update, retrust and uninstall. activate(id,
consent), update and retrust are also the grant-change paths, so "on an
mcp:tools grant change" needs nothing extra. A failed call invalidates
nothing, because nothing changed.

The addon cascade needed its own call. Disabling an addon can stop a
plugin that publishes tools, and the addon doing the cascading is not
necessarily one of MCP_RELEVANT_ADDONS, so mcpAffected does not cover it.
Still gated on a plugin actually stopping, which keeps the #1414 rule
that a config-only save kills nothing.

Imported from sessionManager rather than the src/mcp barrel, which
evaluates readEnv().mcp at module scope.

MCPINV-001 to MCPINV-007.
2026-08-29 12:22:59 +02:00
Renzo Beux 21fd04647f feat(plugins): advertise plugin tools on the MCP server
The advertise path, shaped like trip-warnings.mcp.ts: gate on the kill
switch, fan out over providersOf, let a failing provider contribute
nothing rather than fail the surface.

A tool is advertised only if the signed manifest declares it AND the
running build reported implementing it, the same two-sided check
callPlugin makes for exports. The manifest half is what makes the text
auditable: the child composes its loaded payload after onLoad, so without
it a sha256-pinned artifact could change what it publishes on every
restart with no re-consent.

The result envelope is built here, always. The registry returns a
handler's value verbatim and skips output validation without an
outputSchema, so a plugin returning {ok: true, data} would emit a result
with no content key and every conforming client would error. A failed or
timed-out invoke becomes isError rather than a JSON-RPC fault, so the
model can see it and try something else.

Gates in order: kill switch, then demo mode. The plugins domain had no
demo call site before, and the child has none of its own.

The service owns the process-level source rather than PluginRuntimeService
beside the other sinks, because it needs PluginHooks and PluginHooks
injects PluginRuntimeService. It clears the source on destroy, which the
existing setPluginChannelSource forgets to do: a torn-down buildApp()
would otherwise leave a live source closing over a dead runtime and bleed
into the next suite.

mcpToolCapabilities is a parallel reader rather than a capabilityList
generalisation, since that one returns string[]. It re-runs the manifest
parser instead of trusting the stored blob, which was written by whatever
parser was current at install time and whose caps are the security
control.

Two caps not in the brief: MCP_TOOLS_TOTAL_MAX across all plugins, since
8 each times twenty installed plugins would put 160 tools in front of a
model already choosing from ~200. Truncation is logged, because a
truncated surface reads exactly like a plugin that published nothing.

MCPTOOLS-001 to MCPTOOLS-019.
2026-08-29 12:22:59 +02:00
Renzo Beux c6027f9e68 feat(plugins): report implemented MCP tools at load
The child adds the tool names its mcpToolProvider hook implements to the
loaded payload, beside routes/jobs/exports, and the supervisor caches
them on Supervised like the rest. The hook itself already rides the
existing Object.keys(def.hooks) line, so the "did the child report the
hook too" half was free.

Declarative and synchronous: the host never asks the child what tools it
has. It intersects this list with the signed manifest, so a build that
reports a tool the manifest never declared is ignored rather than
trusted.

grantsOf comes along because the advertise path has to clamp what a
tool's annotations may claim against what the plugin was actually
granted, and sup.granted is already in memory. A per-tool DB read would
be the alternative.

plugin-host-entry.ts is excluded from coverage (it runs in a forked
subprocess), so the two supervisor integration cases are the only thing
proving any of this: the loaded payload, the acting-user-bound invoke,
and that both stop the moment the plugin is no longer active.
2026-08-29 12:22:59 +02:00
Renzo Beux 29de8dd9c1 feat(plugins): capabilities.mcpTools in the manifest
Tools are declared in the signed manifest, not only reported at load.
plugin-host-entry composes its loaded payload AFTER onLoad runs, so a
signed, sha256-pinned artifact could otherwise emit different tool text on
every restart with no version bump and no re-consent. The host will
advertise the intersection of the two.

It also gives the admin something to read. What mcp:tools will put in
front of every user is a manifest field now, so it can be shown on the
consent screen before the grant is given.

Rejects rather than drops, unlike most capability parsing: a malformed
tool should fail the install visibly, not become a tool that silently
never appears once the plugin is running. Caps and sanitising come from
mcp-tool-schema.ts, so the install path and the advertise path cannot
disagree about them.

Declaring tools without mcp:tools is an error on both sides. Otherwise
the consent screen lists tools that can never run.

No migration: install/discovery.ts already persists capabilities as JSON.

The SDK validator mirrors the checks it can make without the server's
normaliser, so `trek-plugin validate` catches a bad declaration before
publish rather than at install.
2026-08-29 12:22:59 +02:00
Renzo Beux 41de91668b feat(plugins): normalise and sanitise plugin-authored assistant text
The security control for plugin MCP tools, landed on its own so it is
reviewable as one diff. Pure and Nest-free: no container, no DB, no
running plugin needed to test it.

The threat is not a plugin crashing. A tool name, title and description
are a persistent, admin-invisible write into every user's assistant
context, and .meta() puts the JSON Schema there verbatim. TREK's own code
proves the channel is obeyed: the static-token deprecation notice is
delivered in a tool result precisely because that is what an assistant
reliably surfaces.

sanitiseAssistantText is a sibling of stripEmoji, not a change to it.
stripEmoji preserves newlines on purpose (PDF paragraphs) and ~15 render
call sites depend on that. The new one collapses every whitespace run to
a single space, so a description cannot forge "## System" as a heading
that reads to the model like host text, and strips more than the brief
asked for: the bidi overrides and isolates and U+2028/9 are neither C0
controls nor \n, so they defeat both halves of the stated rule.

The schema is advertised rather than converted. z.looseObject({}).meta()
passes the plugin's own schema through byte for byte, keeping pattern,
oneOf and format, which a hand-rolled converter flattens to unknown. The
.check() keeps required enforced, so the handler cannot receive a call
missing arguments its own schema declared mandatory.

That only works with a normaliser in front, since .meta() is a
passthrough. It rejects a root type that is not object (which would
advertise a non-object inputSchema while the runtime still passes an
object), rejects $ref/$defs/definitions at any depth, and caps bytes,
properties and nesting. It strips a top-level id, which vanishes from the
advertisement and lands in z.globalRegistry._idmap, a strong Map unlike
the WeakMap holding the rest.

Sanitising recurses into every nested description, title and string enum
member. That is the part a top-level cap misses, and where a property
named trek_session_token with a plausible description would live.

Annotations are clamped asymmetrically. readOnlyHint gates client
auto-approval, so a plugin may lower its danger level and never raise it;
openWorldHint goes the other way, since egress makes a tool open-world
whether it admits it or not. db:own is deliberately not a side effect:
counting the plugin's private SQLite would make nearly every plugin
non-read-only and kill auto-approval for legitimate read tools.

MCPSCHEMA-001 to MCPSCHEMA-030.
2026-08-29 12:22:59 +02:00
Renzo Beux 21f4a1241b feat(plugins): mcp:tools permission and the mcpToolProvider hook
Modelled on the hook:* family rather than on geolocation:read. The host
dispatches INTO the child for every tool call, so it needs what
HOOK_PERMISSION buys: providersOf()'s active-and-holds-the-grant check,
the second gate in invokeHook, and the acting-user binding. That last one
is the point. The user is bound host-side from the supervisor's
invocation map, so a tool called by user A reaches A's data whatever the
model puts in args.

The permission keeps no hook: prefix because the surface it opens is MCP,
not a TREK render slot, and nothing keys off that prefix. It is the first
HOOK_PERMISSION value shaped that way; `satisfies` still holds.

One commit, because it cannot be split. requireTotalCoverage makes a
HOOK_PERMISSION key with no @PluginHook consumer a boot failure, not a
test failure, and PluginRpcRegistryService.onModuleInit runs in every
buildApp() — so a half-landed version breaks every integration test in the
repo. The generator is chained to it too: it refuses to run without both
the en admin label and the CLI hint.

15s is the longest budget of any read hook. The others back a render
someone is waiting on and fall back when they time out; this one backs a
chat turn, and the plugin is likely calling a third-party API of its own.
Still under the supervisor's unused 30s default, which is a hung client
rather than a budget.

One callTool fn for every tool, since tool names are runtime data and
hookContracts() is keyed by (hook, fn).

PluginDefinition exists in two copies with no generator between them, so
both get McpToolProvider. grantGaps and the dev mock host need no change:
both read HOOK_PERMISSION, which is generated.

PLUGHOOK-010, plus the CONTRACTS row.
2026-08-29 12:22:59 +02:00
Renzo Beux 092223c259 feat(mcp): plugins:use OAuth scope
One coarse scope rather than per-plugin granularity. Which plugins are
installed is runtime data, so a per-plugin scope cannot be statically
enumerated: it would break the ${group}:${mode} derivation and the
AssertExact guards in nest-mcp-policy.ts, and its consent copy could not
be written ahead of time for a third-party plugin name. Tokens issued
before a plugin existed could never carry its scope either. Per-plugin
granularity already exists one layer down as the admin's mcp:tools grant.

'use' is a fourth mode, which widens ScopeMode. Nothing else moves:
McpAccessGroup and McpAccessMode derive from Scope, so both AssertExact
guards self-heal, and the policy's fallback branch already covers a third
mode the way it covers files:content.

The presets needed an explicit exclusion. Five of the six are written as
"everything except deletes", so plugins:use would have joined Claude.ai,
Cursor, Windsurf and Zed silently and handed out third-party tool
execution without anyone choosing it. That filter was duplicated across
the desktop and mobile files with nothing pinning it, so both now use one
shared constant, and FE-OAUTH-SCOPES-012 holds the line for the next
scope like this one.

The consent copy says what the scope does not do: the RPC host authorises
from grants frozen at construction, so this scope plus weather:read still
reaches whatever the plugin itself was granted.

Two i18n gates, not one. The dot-only group key is tracked by
i18n-parity.mjs, whose regex has no colon in its character class; the
colon-bearing label/description pair is invisible to it but is compared by
client/tests/unit/i18n/parity.test.ts, which diffs merged bundles. All 23
locales get all three keys, including the three in neither gate.

The consent glyph gets a ':use' branch. Falling through to the eye would
tell the user a tool-running scope only looks at things.
2026-08-29 12:22:59 +02:00
Renzo Beux c56521b4a4 feat(server): process-level plugin MCP tool source
Sibling of plugin-event-sink.ts: the plugins layer registers a source, and
src/mcp/tools.ts reads it as a defaulted argument. mcp-transport/ is not
touched at all, so it never learns that plugins exist and the tool-call
audit row for a plugin tool comes out of code that did not change.

Pull rather than push, unlike the event sink, because the answer depends
on the session's ctx: there is nothing a push could carry ahead of time,
and reading it per attach means activating or uninstalling a plugin takes
effect on the next session with nothing to invalidate. Same argument
channel-registry.ts makes for setPluginChannelSource.

An unset source means "no plugin tools" rather than an error, the right
answer for any app built without the plugins runtime. MCPSINK-003 pins
that case, which none of the sibling sinks covers today.

Not try/caught here: attach() already contains a throwing source and logs
which one failed, and catching again would swallow that.

The harness takes dynamicTools explicitly so suites exercise the path
without installing a process-level source.

MCPSINK-001 to MCPSINK-005.
2026-08-29 12:22:59 +02:00
Renzo Beux a6c96f4053 feat(nest-mcp): per-session dynamic tool source on McpAttachOptions
A host can now contribute tools for one session through
McpAttachOptions.dynamicTools, consulted once per attach() with that
session's ctx.

It belongs here rather than in the host calling server.registerTool()
itself, because attach() is where the onInvoke seam fires. Anything
registered outside it is invisible to the tool-call audit trail, and
handlers that run as the calling user are the last thing that should sit
in an audit blind spot.

The source is synchronous: attach() runs inside the initialize request,
so an awaited source would put a third party's round trip in front of
every session creation.

Two properties carry the weight, both security ones:

- Names are reserved against every registered entry, not against what
  this session was granted. Reserving against the session would mean a
  token scoped to trips:read has no create_trip attached, so a
  contributed tool could take that name for exactly the callers holding
  no scope for it.
- Nothing a contributor does can make attach() throw. Hosts call it
  outside their request try block, so an escape is a 500 on every
  initialize and one bad contributor takes MCP down instance-wide. The
  source is contained once, each entry again. Both live throw sites are
  covered: the SDK duplicate-name throw, and the policy-less declarative
  access throw that registered entries only avoid because validate()
  pre-checks them at boot.

ToolOptions.inputSchema widens to ZodRawShape | McpZodSchema. The SDK has
accepted either since it normalises both to one object schema, and a
whole schema is what lets a host advertise one it did not author (via
.meta()) without converting it to Zod first. The arms are disjoint, so
nothing else moves; one test cast in decorators.test.ts records which arm
that decorator declared.

DYNTOOL-001 to DYNTOOL-014.
2026-08-29 12:22:59 +02:00
Renzo Beux b4f00571dc refactor(mcp): move invalidateMcpSessions into sessionManager
The body was a hand-inlined duplicate of sessionManager's own closeSession
helper, so it moves next to revokeUserSessions and reuses it. The barrel
re-exports it, leaving admin.service.ts and admin.controller.ts untouched.

The reason to move it is that callers outside src/mcp/ are coming. index.ts
evaluates readEnv().mcp at module scope and installs the sweep interval, so
importing the barrel from a domain module drags both into every test file
that mocks app-config with a partial export set, which is the hazard kill-switch.ts
documents. sessionManager.ts has no module-scope side effects.

The log line loses "due to addon state change"; addons stop being the only
thing that invalidates a session surface.

SESS-017 to SESS-020 cover it.
2026-08-29 12:22:59 +02:00
Maurice c8c11fdb7a fix(places): pass the mapped candidate to the SQL half, and state where the two still differ
The importer's parsed item spells the provider id `googleFtid`; the match rule
reads `google_ftid`. `isPlaceDuplicate` was handed a mapped object, but
`findDuplicatePlace` got the raw one, so its id strategy was always empty. The
name then decided the match, and a second place sharing that name could take the
ftid belonging to the renamed one. One candidate now serves all three calls.

Sharing the strategy list shares the ORDER, not the comparison, and two
differences survive it. SQLite `lower()` is ASCII-only where JavaScript's is not,
so a row stored as CAFÉ CENTRAL does not match the candidate Café Central in SQL
while it does in memory. The coordinate fallback used to paper over that for a
named candidate, sometimes with the wrong row. And `buildDedupSet` collects
coordinates only for unnamed rows while the coordinate query considers every row,
which is the answer `findMatchingPlaceId` wants and the opposite of what the
in-memory half says. Both are now written down where someone will read them, and
pinned by tests, rather than implied away.

PLACE-SVC-028e covers the backfill landing on the right row. PLACES-SVC-014 to
-017 cover the id beating a misleading name, both remaining divergences, and the
tolerance as a number rather than as a reference to itself.
2026-08-29 12:22:46 +02:00
michael-bohr 84b51a8af5 refactor(places): one door onto the place-matching rule
Two consumers decide whether a candidate place is one the trip already has,
and they spelled the rule out separately: the bulk importers ask an in-memory
set (isPlaceDuplicate), while findDuplicatePlace asks the database because its
caller needs the matched row's id. The two had drifted — the SQL copy falls
back to a coordinate match for a NAMED candidate, which the in-memory copy
deliberately never does, since that would merge the restaurant and the bar at
the same address.

The order now lives once in @trek/shared as placeMatchStrategies(), and each
consumer is a mechanical interpreter of it: provider id (the only part that
survives a rename, #1550), then name, then coordinates and only when there is
no name.

Adds PlacesService.findMatchingPlaceId as the public door for an importer that
has to link to the existing place rather than merely skip the candidate.
findDuplicatePlace stays private behind it — it also returns google_ftid for
the bulk importer's backfill, which is that caller's business and not part of
"which place is this?".

No behaviour change for either existing caller. The coordinate branch is
currently unreachable from the one call site, which sits inside an
isPlaceDuplicate() guard that has already matched on name and returned; the
drift only bites once something calls it standalone.

COORD_DEDUP_TOLERANCE and externalIdsOf move to shared and are re-exported from
places.helpers, so collections.service and places.service keep their imports.
2026-08-29 12:22:46 +02:00
Maurice 4854377603 fix(oidc): stop the login page signing a user back in against their will
Two reports, one line: the automatic bounce to the provider in useLogin,
which fires whenever password login is off. Both reporters are on an
OIDC-only install, which is what arms it.

#2126, the redirect loop. The re-entry guard sat inside the oidc_code
branch, but the exchange strips oidc_code from the URL before it
navigates away. Any re-run of the effect during that window - and t is a
dependency, so a language change is enough, which every non-English
install does on the login page - read an empty search, missed the guard,
fell through to the config probe and bounced to the provider. The
provider answered silently, landed back on /login?oidc_code=, and round
it went. Each pass mints a valid session, which is why closing the tab
and reopening lands the reporter logged in. The guard is now the first
statement of the effect, so no re-run can reach the redirect.

#2123, logout that logs you back in. The brake for the bounce was
noRedirect in router location state, and it does not survive the trip:
logout flips isAuthenticated on a sync lane while the router defers its
location update, so ProtectedRoute renders once on the old route and
emits <Navigate to=/login replace> with no state, overwriting the entry
the logout handler had just pushed. Location state cannot cross a full
document load either, and api/client.ts performs one on any 401. The
fact now lives in sessionStorage, set in authStore.logout() so all seven
sign-out call sites are covered, and cleared on any successful auth and
on the manual SSO links. Per tab, so a fresh tab still single-signs-on -
that being the point of single sign-on.

Both tests were confirmed red without their fix and green with it. The
first needed rewriting to do that: the obvious version held the page
mounted past the window the race lives in and passed either way.

Not included: RP-initiated logout at the provider. TREK clears its own
session correctly, and ending the provider's is a separate decision -
it needs an end_session_endpoint the provider may not advertise, a
post_logout_redirect_uri registered on their side, and an answer to
whether signing out of TREK should sign you out everywhere.
2026-08-29 01:30:26 +02:00
Maurice 8069b88057 test(admin): wait for the migration POST instead of the save toast
FE-ADMIN-STOR-036 went red on a loaded runner while every assertion in it
passed on the run before. The two storage tests that capture the migration
POST body read it immediately after awaiting the "Storage configuration
saved" toast - but that toast comes from the PUT, and AdminStoragePanel fires
the migration POST from a later effect reacting to the refreshed admin.state.
The two are not the same tick, so reading the body straight after the toast
only held while that effect happened to flush first.

Both now await the body through waitFor. A genuine regression still fails,
with the same assertion error, once the wait times out.
2026-08-29 00:51:43 +02:00
Maurice 44a117390b test(maps): stub maplibre-gl-leaflet globally
jsdom has no WebGL, so the real vector basemap layer can never work in the
client tests. More immediately: several map tests hand react-leaflet a partial
useMap() stub with no addLayer on it, which was harmless while the basemap was
a stubbed <TileLayer> and is not now that it is a real layer - addTo() reaches
for map.addLayer and rejects into the void. 79 unhandled rejections, every
assertion still green, and vitest fails the run on those.

Stubbed in the setup file for the same reason ResizeObserver is: the capability
does not exist in this environment. The tests that assert on the layer register
their own mock, which takes precedence.
2026-08-29 00:51:43 +02:00
Maurice b401da2264 fix(maps): default the Leaflet basemap to OpenFreeMap
CARTO started stamping "API KEY REQUIRED" across every keyless basemap tile
on 26.08.2026. #2060 added a key field as the immediate answer, but it leaves
a self-hosted instance needing an email exchange with CARTO before its maps
look right, and every default in the client still pointed at CARTO. Default
to OpenFreeMap Positron instead: no key, no account, no request limit,
commercial use allowed, attribution required.

OpenFreeMap serves vector tiles only, so the Leaflet basemap is a MapLibre
style document rather than an XYZ template. maplibre-gl-leaflet hangs a GL
canvas into Leaflet's own tile pane, which leaves everything above it alone -
markers, GeoJSON, clusters, plugin layers, the custom panes, and the click
handling, since Leaflet's CSS already puts pointer-events:none on that layer.
maplibre-gl still arrives only through a dynamic import, so no chunk that
draws a map grows by a megabyte.

Switched: the trip planner, the MapView fallback every preview goes through,
the journey map, collections, the atlas, the shared trip page and the offline
tile prefetch. OpenFreeMap Positron and Bright join the tile presets in
settings, the admin defaults and both mobile screens.

A CARTO template that is already saved keeps working when a key backs it -
that stays a supported choice. Without a key it is not drawn at all:
resolveTileUrl falls back to the default rather than to a watermark, so an
instance that upgrades with one in its settings sees the new basemap
immediately and needs no migration. The stored value is left as it is, so the
Map settings tab still shows what was chosen and the warning underneath it
explains why - one sentence appended, in all 23 locales.

OpenFreeMap asks for a credit of its own; OpenStreetMap alone under its tiles
is wrong and a licence problem. attributionForTile() derives it from the URL
rather than from a flag at each call site, and the shared trip map, which
carried no attribution at all, now gets one.

Offline maps keep working and get cheaper. A vector style is four things
instead of one, so sync/glPrefetcher.ts walks the style document, its sprite,
the Latin glyph ranges of each font stack it uses, the TileJSON - the planet
path is versioned, so guessing it answers 200 with an empty body - and then
the .pbf tiles. Over the Berlin bounding box TREK computes, raster z10-16 is
2237 requests and 34.6 MB and gives up above z16; vector z10-14 is 177
requests and 32.1 MB and stays sharp above that, because MapLibre scales the
z14 tile rather than asking for another. The Workbox rule for
tiles.openfreemap.org moves to CacheFirst and shares the prefetcher's cache,
so browsing tops a downloaded region up instead of filling a second copy
beside it - a versioned tile path can never be stale. Clearing offline maps
clears it too.

The atlas loses its second raster layer. It existed to warm the HTTP cache of
neighbouring zoom levels, which a vector basemap does not need; a second one
would only cost a second WebGL context. OpenFreeMap has no label-free style,
so its labels are switched off on the live map instead - they are all symbol
layers, while borders and coastlines are line and fill - re-armed on
style.load so a theme switch keeps them off.

Theme changes call setStyle in place rather than rebuilding the layer: a swap
per toggle would leak a WebGL context, and browsers cap those in the low
teens.
2026-08-29 00:51:43 +02:00
Maurice 1d12535b1b fix(map): position place pins in the map's render cadence on MapLibre
The place pins visibly swam back and forth while the map was dragged and only
settled onto their real position when it stopped. The clustered bubbles never did,
and that is the clue: those are drawn inside the canvas, the pins are HTML.

A library Marker repositions itself on the map's 'move' event, which fires once per
pointer sample, while the canvas is redrawn once per animation frame. When the style
is heavy or tiles are still streaming, the pins therefore run ahead of the map they
are supposed to be pinned to.

They now live in their own layer inside the canvas container and are positioned from
the map's own 'render' event, so pin and map move on the same clock. Mapbox keeps the
library marker: its terrain path needs the altitude handling that comes with it, and
the drift was never reported there. Clicks, hover and dragging a pin onto a day are
untouched — they hang off the element, which is the same element as before.
2026-08-28 22:21:01 +02:00
Konstantinos Thermos 9e0fb19e79 fix(studio): keep the auto layout inside the book contract
Auto layout wrote journey data straight into the document without holding
it to the caps the contract sets, and the save path sends the document
unvalidated - so the route refused the whole body with a 400 and the
editor reported "couldn't save" for the rest of the session, because
autosave kept offering the same rejected book.

The everyday way in was the itinerary list: thirteen stops a page over
two pages is eighty elements on a spread capped at sixty, so a run of
twenty bare stops was unsavable and a longer one put two spreads over.
Sixty was below what this app's own layouts draw, which makes it a bug
with a constant in front of it rather than a limit, so the cap goes to
ninety and the page keeps the design somebody chose. It stays a
serialisation limit: the largest document the contract now admits is
under four megabytes against the book route's eight.

A long journal entry, a long place name, a journey past eighty countries
and a long title each did the same thing on their own, and a bound story
did it on open without anyone touching Auto layout. The caps become named
exports on the contract and every producer builds against them, so the
two cannot drift apart again - which is also why the stations chunk is
derived from the cap and the salvage fixture counts off it.

Fixes #2085
2026-08-28 22:01:06 +02:00
Maurice 8f6d5fd21f fix(dashboard): stand in for the trips and the stats while they load
The dashboard rendered its finished empty state while it was still fetching: no
hero, no cards, and a stats row reading zero everywhere. On a slow connection
that reads as "my trips are gone" rather than "not here yet", which is what the
report describes.

The stats row was the worse half. It comes from a separate call that carries no
loading flag at all, so every tile fell back to its zero until the response
landed. A dash placeholder says "not yet" where a 0 says "none".

The skeletons already existed. SpotlightSkeleton and TripCardSkeleton were
written for this page, described as matching its layout, and never wired up:
JourneyDetailPage was the only consumer of that module.

Fixes #2115
2026-08-28 21:20:59 +02:00
Maurice 71ff191bb4 fix(vacay): keep guest accounts out of the invite pickers
Guests are trip-scoped accounts. Trip Members excludes them from its picker and
refuses them on the write path, and Collections does the same, but Vacay's two
directories did not: a guest stayed selectable in a plan's Invite User dialog
and in the shared-calendar picker, including after being removed from the trip
it was created for.

The queries are server-side, so the filter belongs there rather than in the
client, which never received the flag to filter on. Both write paths get it too:
the picker only hands out a list, the id comes back from the client, and the
Vacay MCP tools reach the same two service methods.

Fixes #2112
2026-08-28 20:11:28 +02:00
Maurice 63a57b9269 fix(atlas): stop counting address abbreviations as countries
The Atlas hero counted more countries than the continent bars below it drew,
and more than the dashboard widget. The extra ones were invented: a place
without coordinates fell back to parsing its address, and any two-letter
uppercase segment at the end became a country code. "New York, NY" produced NY,
"Toronto, ON" produced ON.

Those phantoms only ever showed up in the one number. The continent strip files
an unknown code under 'Other' and never draws it, the map skips codes it cannot
resolve, and the dashboard builds its set from stored regions instead, which is
why it stayed right.

Filtering against a list of valid codes would not have been enough, and would
have hidden the worse half of this: 26 US state abbreviations are real ISO
country codes. CA is California far more often than Canada, DE is Delaware, IN
is Indiana. The existing comment above resolveCountryCode already describes that
collision and solves it by trusting coordinates first and sanity-checking the
address against the country's own bounding box. The gap it names in its last
line is this bug: with no coordinates there is nothing to check against.

So the bare-code branch is now only offered to callers that have coordinates. A
spelled-out country name still resolves either way. A lone "NY" or "CA" resolves
to nothing, which is the honest answer.

Fixes #2111
2026-08-28 20:11:28 +02:00
Maurice 4f15d87cbe fix(oidc): drop the old provider's avatar and identity when the IdP changes
Pointing the instance at a different OIDC provider left two things from the old
one on every account. The avatar kept a URL on a host this instance no longer
talks to, so it rendered as a broken image and stayed that way: the sync only
ran when the new provider sent a picture claim, and having no picture for a user
is a perfectly normal answer.

The account also stayed pinned to the old provider. Login still worked, because
the verified-email lookup finds the row, but the relink sat behind a check for a
missing sub, so oidc_sub and oidc_issuer never moved. The SSO note in settings
went on naming a provider that was gone.

An uploaded avatar is a bare filename rather than a URL and is left alone on
both paths, exactly as before.

Fixes #2110
2026-08-28 20:11:28 +02:00
Maurice adf88e1d61 fix(reservations): accept a booking that starts and ends on the same day
A permit valid for one day has no clock on either end. isEndBeforeStart filled
both missing times with midnight and then compared strictly, so start and end
came out equal and the form refused it: inline error, dead save button, toast
on submit.

It also made existing rows uneditable. A booking whose end is a bare date on the
start day is exactly what the booking import writes, and what the mobile sheet
has been allowed to save since 72a82b3c. Opening one of those on the desktop
left the save button dead with no way to clear it.

With a time missing on either side the booking is all-day, so only the dates are
compared. An end date before the start is still refused, with or without times,
and two explicit clocks still have to be in order.

The hotel guard comes along for the same reason mobile carries it: the date
panel is hidden for hotels, so switching type after typing dates left the button
disabled with its explanation inside the hidden block.

Fixes #2107
2026-08-28 18:34:47 +02:00
Maurice 910c62ea90 fix(transit): show the stop you get off at instead of the walk that follows
Every row of an expanded search result is anchored at its leg's start: the time,
the stop name, the platform. A leg's arrival appears as the next row's heading,
and the last one gets the closing row. The walking legs broke that and printed
their DESTINATION, which is the stop the next row already names.

MOTIS brackets every journey in walking legs, so the last leg of a real
connection is the walk from the arrival station to wherever you were headed.
Its origin is the stop you get off at, and because that row printed the
destination instead, the station had no row anywhere in the card and vanished.
The same branch blanked the time cell, so the train's arrival time went with it.
At the other end the access walk swallowed the origin the user had picked.

Rows now read from.name for every mode, and the walk says it is a walk in its
badge row instead. The platform tags along, which gives the exit row the arrival
platform it was already carrying. The time falls back to scheduledTime the way
the save path does, since a schedule-only feed would otherwise leave the row
that matters without a clock.

Fixes #2106
2026-08-28 18:34:47 +02:00
Maurice 6c7e257c17 test(plugins): cover the mock double and the child proxy for addEntryPhoto
The handler had eight cases, its two doubles had none, which Sonar counted as
new code without coverage. The mock is the more useful of the two to pin
anyway: an importer developing against it should hit the same refusals it will
hit in production, so the SVG, the missing extension, the empty payload and the
size cap are all asserted against the double rather than only against the host.
2026-08-28 18:04:25 +02:00
Maurice 661777f81f feat(plugins): let a plugin attach a photo to a journal entry
A journal importer can create the journey, create its entries and write their
stories, and then it stops: there was no way to give an entry a picture. The
seven journal.* methods cover text and nothing else, so an importer reading an
export archive could only upload its photos as trip files, which live on a trip
and never reach the journey's gallery.

journal.addEntryPhoto takes the bytes. Deliberately not a link to an existing
gallery photo and not a provider asset: an importer holding an archive has
neither, it has a file it just unpacked.

The access decision stays where it already was. addPhoto answers null unless the
acting user may edit the journey the entry belongs to, and null becomes
RESOURCE_FORBIDDEN here, so a plugin reaches exactly the entries its acting user
does. Everything the REST upload refuses, this refuses too: images only and no
SVG, the operator's allowed-file-types setting, 10MB decoded, demo mode. The
stored filename is the host's, never the plugin's, because the gallery dedupes
on file_path. The object is written before any row points at it and removed
again when the link is refused.

JournalRpc moves into its own module for this. It now needs StorageService and
the EXIF backfill out of MemoriesModule, and JourneyDomainModule exists
precisely so places, assignments and the plugin host can reach a few journey
functions without dragging both photo providers into their graphs.

Discussion #1365
2026-08-28 18:04:25 +02:00
Konstantinos Thermos 1c5c3ae983 fix(atlas): retile the map instead of rebuilding it when the CARTO key lands
useTileUrl returns a new string once carto_api_key reaches the settings
store, which happens after the first render. That string was a dependency
of the effect that *builds* the Leaflet map, so the key arriving tore the
map down and built a new one.

Two things break in that window: Leaflet finishes a queued redraw against
the canvas renderer whose map has gone ("Cannot read properties of
undefined (reading 'save')"), and the country layer never comes back —
its effect only re-runs when its own data changes, so the rebuilt map
shows tiles and nothing else.

The template belongs to the tile layer, not to the map. Hold the layers
in a ref, read the template through a ref inside the build effect, and
setUrl them from an effect of their own. Same fix in JourneyMap, where
cartoKey and mapTileUrl sat in the dep arrays of the effects that draw
every marker and track.

Fixes #2097
2026-08-28 16:04:38 +02:00
Maurice 5a7b8d75aa feat(api): add GET /api/v1/stats for dashboard widgets
Homepage's customapi widget and everything shaped like it want a handful
of numbers from one request; /api/v1/trips hands out the rows, but a
widget maps fields, it cannot aggregate a list.

The figures come from AtlasService.getTravelStats, the same call behind
the dashboard's passport card, rather than from a second count. Visited
countries carries years of rules — countries reached only by a flight,
layovers that do not count, countries hidden by hand — and a second
implementation would disagree with the card next to it within a release.

That is also why the controller sits in atlas/ and not in public-api/:
PublicApiModule stays a leaf, so the route comes to the data instead.
TravelStatsController next door already does this for
/api/auth/travel-stats. ApiTokenGuard is provided locally rather than
imported with PublicApiModule — @UseGuards instantiates a guard in the
declaring controller's module, so an export would still leave its
TokenService unresolvable here.

last_trip means the most recently started trip, not the most recently
created one, and is null while every trip is still ahead.

Closes #1367
2026-08-28 15:23:05 +02:00
Maurice 7cbd4e5c42 test(auth): cover the api-token routes
The three api-tokens endpoints landed without tests, which took
src/nest/auth/** under its coverage floor. Same four paths the
mcp-tokens block above already pins, plus the rate limit: unlike MCP
tokens these are not refused on a managed instance, so the limiter is
the only thing bounding a scripted caller.
2026-08-28 15:23:05 +02:00
Maurice 1e1a80c199 feat(api): expose shortlisted places, detached bookings and the bucket list
Reading the surface back against real data turned up three things it dropped
without saying so.

Places with no day were invisible, because the payload only reached places
through day_assignments. On a live instance that is half of them, and they
are the ones carrying a coordinate and no claim about when — exactly what an
integration can act on. They come back under `unplanned_places`, minus the
hotel, which is already reported as an accommodation.

Bookings with no day were dropped by the day filter. reservations.day_id is
nullable and deleting a day detaches its bookings rather than removing them,
so this is not a hypothetical.

The bucket list had no endpoint at all. It hangs off the user rather than a
trip, so it gets its own, and it answers whether or not the Atlas addon is
switched on: the addon decides what TREK shows, not what a key can read.

Also fixes ?include=notes returning the bare trip: places, notes and
reservations are reported on days, so asking for one now implies days
instead of quietly answering with nothing.

The module stays a leaf. Reaching for AtlasService pulled AuthModule and the
storage registry in behind it and broke the surface's own boot, so the one
SELECT lives here like every other query in the file.
2026-08-28 15:23:05 +02:00
Maurice d559fdda1d feat(settings): give API keys their own section
Minting a key for other software meant finding it under MCP, behind a
heading about AI assistants and gated on an addon it does not need. It now
sits on its own in Settings, above the MCP block, and works on an instance
with MCP switched off.

Deliberately separate state rather than another branch of the tab's shared
hook: the section has to stand on its own, and that hook is already carrying
forty fields through three levels of props.

Translated into all 23 languages.
2026-08-28 15:23:05 +02:00
Maurice 4b120d39c8 feat(api): separate API keys from MCP tokens, add travellers
An MCP token drives every assistant tool; an API key reads trips over HTTP.
Handing the same credential to a third-party integration gives it the whole
MCP surface, so the two are now separate kinds on `mcp_tokens` and the kind
sits in the WHERE clause — a token of the wrong kind is indistinguishable
from one that does not exist.

Also adds `travellers` to the trip payload: display name and an owner flag,
no ids and no email addresses. An integration key reads its owner's
itinerary; it is not a way to enumerate the people around them.

Documents the surface in the wiki, since integrators need something to read
that is not the source.
2026-08-28 15:23:05 +02:00
Maurice 9cddf683a5 feat(api): rate-limit /api/v1 and report when a trip last changed
Two gaps from the first pass, both about a client that polls.

Rate limiting is keyed by user rather than by IP. A self-hosted integration and
its owner's browser routinely leave the same address, and an IP bucket would let
one starve the other; 120 requests a minute is generous for a sync surface and
still bounded, so a runaway poll degrades its own integration instead of the
instance.

updated_at now ships with every trip, and its docblock says plainly what it is
not: a content timestamp. It moves when the trip's own fields change — title,
dates, currency, cover — and stays put when a place, a day or a note is edited,
because the child tables carry no updated_at of their own. That also rules out
the ?since= filter this was meant to enable: it would silently hide every trip
whose itinerary changed, which is worse than no filter at all. Making it honest
needs a schema change, not an API field, so the field is documented for what it
is and the filter is left out.
2026-08-28 15:23:05 +02:00
Maurice fd6381c814 feat(api): add a versioned read-only public API for integrations
TREK's REST surface is internal: it answers a session cookie, carries no
version, and changes with the client. That is fine for our own frontend and
useless for anyone building against us — the Dawarich integration people have
been asking for since April needs something it can rely on not moving.

So this adds a small one rather than opening the existing 549 endpoints:

  GET /api/v1/trips           the trips the caller can reach
  GET /api/v1/trips/:id       one trip, ?include= picks the sections

Auth reuses the trek_ tokens users already manage under Settings, via Bearer
or X-API-Key. No new credential type, no new table — the guard calls the same
hash lookup the MCP transport does. It deliberately refuses a session JWT and,
for now, OAuth bearers: a credential whose scopes this surface does not yet
interpret is worse than one it rejects outright.

Access is decided per trip against canAccessTrip, the same owner-or-member
predicate as everywhere else, and a trip the caller may not read answers 404
identically to one that does not exist — a 403 would confirm the id is real and
turn the endpoint into a way to count someone else's trips.

The payload is not the rows. Ids, foreign keys and order_index stay inside;
accommodation day ids resolve to ISO dates, places arrive in planned order, and
every dated thing carries its date so a consumer can join on days rather than
on ids it has no way to look up.
2026-08-28 15:23:05 +02:00
Maurice d2402c798c fix(llm-parse): read an arrival that arrives as a bare clock again
fixArrivalDate normalized the arrival and then looked for the time behind a
leading date. The model usually sends the arrival as a bare clock, "13:00" with
no date on it, and slicing ten characters off that leaves nothing to match, so
the function returned early and stopped deriving the arrival date at all.

Try the bare 12-hour form first, then fall back to the normalized string, which
covers both shapes: "13:00", "01:11 pm", and a full date-time.
2026-08-28 14:25:08 +02:00
Maurice d091f4a815 fix(shared): read the regex groups defensively in the datetime helper
shared compiles with noUncheckedIndexedAccess, so a capture group is
string | undefined there even when the pattern guarantees it. Pull the groups
through optional access with explicit fallbacks instead of indexing into the
match, which also drops the two throwaway locals.

No behaviour change: same inputs, same outputs, the spec is untouched.
2026-08-28 14:25:08 +02:00
Maurice 130272cfb0 fix(booking-import): stop writing an unreadable type down as a hotel (#2076)
When neither the keyword table nor the model produced a type, the router wrote
"hotel" and cast whatever string it got to FlatType without checking it. A hotel
is a booking type, so a ferry voucher imported from the Transport tab opened the
booking form, which offers six chips and not one transport among them, and
"other" was the only honest pick left. An unrecognised string got through the
cast too and then vanished silently in nuExtractToKiReservations.

The type is validated against FLAT_TYPES now. When it cannot be read the item is
still extracted with the lodging shape so it survives mapping, but it says so,
and the tie-breaker #2080 added finally has something to fire on: isUnplaceableItem
only ever matched a type outside both form's vocabularies, and the server can
only emit its own eight, so on a real document it never matched at all.

The tab travels with the job instead of sitting in component state. The parse
deliberately outlives navigation and reload and the review is triggered by the
global widget on any page, so by the time it ran the state had remounted back to
its default and the context was gone.
2026-08-28 14:25:08 +02:00
Maurice 016fdeec64 fix(booking-import): read a printed 12-hour clock instead of slicing it (#2094)
splitIso reached the time by taking characters 11..16, so "2026-06-11T01:11 PM"
became 01:11. The hour survived and the half of the day did not, which is why
AM and 24-hour times in the same document imported correctly. The arrival then
sorted before the departure and the router rolled it onto the next day, so a
three-hour hop was rendered as an overnight.

The client has done this properly since #1725; this is the server half, which
was never pulled across. normalizeLocalDateTime rewrites a value only when it
actually finds a meridiem, so a date-only value, a value carrying an offset and
anything the parser does not know come back byte for byte.

The mapper has only two exits for a date, toIsoString and splitIso, so this
covers all eight types and every emitted field at once. It also stops
sameConnection comparing a NaN timestamp, which silently merged unrelated legs
into one booking.
2026-08-28 14:25:08 +02:00
Maurice 15588d20c9 fix(feeds): keep a staged booking out of the public feed and the shared trip
The ICS feed is public with a token that never expires and prints the
confirmation number into the DESCRIPTION, and the shared-trip payload does a
SELECT * over reservations with share_bookings on by default. Neither filters
anything, so whatever lands on a trip is published the moment it is written.
That is fine while a person types every booking in, and it stops being fine as
soon as something writes on their behalf.

reservations.ingest_state is that gate. It defaults to live, SQLite backfills
every existing row on the ALTER, and no current writer names the column, so
every booking visible today stays visible. Nothing writes staged yet.

The predicate takes its table alias because share.service.ts queried
reservations without one. A stay is withheld only when bookings point at it and
all of them are staged: a hand-added hotel has no booking at all and has to keep
showing up.
2026-08-28 14:25:08 +02:00
Maurice 8641788de0 docs(wiki): the new MCP surfaces
Files, settings, calendar feeds, invite links, the AirTrail import, provider
photos, help and the addon list. Also says why accepting an invite and
reading photo bytes have no tools.
2026-08-28 14:20:02 +02:00
Maurice 66530a074f feat(mcp): register the new surfaces, and point the model at the units
Ten new controllers in the test registry, and one line in the session
instructions: without it nothing tells an assistant to read the display
settings before rendering a temperature, which is what makes that tool worth
having.
2026-08-28 14:20:02 +02:00
Maurice 8a846e4b91 feat(mcp): read the warnings plugins raise about a trip
A plugin warning is a plugin telling the user something is wrong with their
trip, and get_trip_summary omitted them, so an assistant reviewed a trip
from a strictly smaller picture than the user was looking at.
2026-08-28 14:20:02 +02:00
Maurice fa67a29e76 feat(mcp): find provider photos and attach them to a journal entry
The whole memories domain had no tools, so the Immich and Synology
libraries were invisible even though the attach body is plain JSON and the
photos never pass through the call. Search, album listing, album contents,
and the attach.

The routes that stream photo bytes stay out: those are img targets.
2026-08-28 14:20:02 +02:00
Maurice dd469f7d1a feat(mcp): import flights from AirTrail
TREK's own bulk travel-history importer, and the closest thing in the
product to what this whole branch is about. Both halves are server-side
JSON, so neither was ever browser-bound; they simply had no tools. The list
tool's output carries what the import tool needs, so a model can run the
pair without a detour through the UI.
2026-08-28 14:20:02 +02:00
Maurice fa79923663 feat(mcp): calendar feed tokens and the trip invite link
export_trip_ics is a snapshot of one trip, so subscribing was out of reach
and the all-trips feed had no counterpart at all. Both feed tokens can now
be read, minted, rotated and revoked.

The invite link is the one that grants membership, which create_share_link
is not, and the descriptions say so in both directions so a model does not
reach for the wrong one. The two consumer routes stay out: the ics URLs are
what gets subscribed to, and accepting an invite is a human act.

test-db's reset list was missing trip_invite_tokens, so a leftover invite
re-attached itself to the next test's trip once ids restarted.
2026-08-28 14:20:02 +02:00
Maurice 9ed35d182f feat(mcp): the bundled help pages, and which addons are on
131 of 201 tools carry an addon gate, and the registry drops an entry when
either the addon gate or the scope gate fails: one filter, one outcome. The
session instructions tell the model to check what is available before
assuming a tool exists while giving it nothing to check with. list_addons is
that something, and it is the difference between a confused apology and
'ask your admin to enable Budget'.

The help routes were already public, so the two reader tools add no scope
surface at all. The asset route stays out: it serves bytes to an img tag.
2026-08-28 14:20:02 +02:00
Maurice ed67a5e9f8 feat(mcp): read and change display preferences
An assistant rendering a temperature, a distance or a clock time was
guessing: the session instructions named only the trip currency, and nothing
could read what the user had picked.

The write works off an allow-list rather than off the settings row, because
that row also holds the webhook URL, the ntfy token and the Mapbox, Carto
and LLM keys, and getUserSettings hands some of those back in cleartext.
Everything outside the list is refused, including a key nobody has heard of
yet. The guard test asserts against the service's own encrypted and masked
sets rather than a copy of them, so a sixth key added there fails it.
2026-08-28 14:20:02 +02:00
Maurice 6270495327 feat(mcp): read and organise the documents on a trip
Often the travel history is the uploaded PDFs, and an assistant could not
learn that a trip had any: there was no files.mcp.ts, no files scope, and no
files section in the trip summary.

Six tools: list, read contents, set description and primary link, add a
link, remove one, list them. Reading contents rides files:content rather
than files:read, because listing what a trip carries and reading what is
inside a booking PDF are different privileges.

The content read is not a second copy of the plugin path. readContent moved
out of files.rpc.ts into FilesService and both callers use it, so the 10 MB
cap, the storage-layer read and the off-thread decode stay in one place. The
RPC keeps its own error wording, which its tests still pin.
2026-08-28 14:20:02 +02:00
Maurice 42ba5d4849 fix(mcp): keep the categories module out of the auth graph
The admin gate on the three new category tools reached for AuthService, which
drags the storage registry in behind it, and the categories e2e builds a
container around one module against a two-table database. It stopped booting.

budget/ already solved this: isDemoUserId reads RuntimeEnvService and the
users table directly, and its module comments say why AuthModule is absent.
Categories now does the same, which also takes eight hand-built collaborators
out of its unit test.

The e2e gained RealtimeModule, which is global in the app graph but not in a
partial container, the way days.e2e.test.ts already imports it.
2026-08-28 14:20:02 +02:00
Maurice ab3751761c feat(mcp): files and settings scope groups
Two domains an assistant cannot reach at all today need somewhere to hang,
so this is the credential half on its own, ahead of the tools.

files:content is a third mode rather than a second group. Listing the
documents on a trip and reading the bytes inside a booking PDF are different
privileges, and the plugin host already draws that line between
db:read:files and db:read:files:content. It is not implied by files:write,
the same way journey:share is not implied by journey:write; the policy's
fallback branch covers both.

settings:write is scoped to display preferences. Settings also holds the
webhook URL, the ntfy token and the Mapbox, Carto and LLM keys, so the tools
that ride this scope work off an allow-list rather than off the settings
row.

34 scopes across 16 groups, translated into all 23 languages.
2026-08-28 14:20:02 +02:00
Maurice aa251cdb8e test(mcp): construct CategoriesMcp with the deps its tools now need
The three admin category tools gate on AuthService and the shared guards, so
the hand-built registry the MCP harness uses has to hand them over too.
2026-08-28 14:20:02 +02:00
Maurice 2ba10a9126 docs(wiki): the tools and fields added across the MCP surface
Also drops the em dashes from the earlier entries in this branch.
2026-08-28 14:20:02 +02:00
Maurice 44c2573abb feat(mcp): trip length, reminders, cover search, and the member permission
A dateless trip made by a tool silently got seven days, and reminder_days
was stuck on the default of three with no way to turn it off. Cover images
had no search at all.

add_trip_member and remove_trip_member hardcoded an owner check while REST
consults the configurable permission matrix and the plugin RPC does too:
three surfaces, two behaviours. They now use the same permission call
update_trip already made, so an instance that lowered member_manage behaves
consistently. The guest tools stay owner-only, because their REST routes are
owner-only by design and document it.
2026-08-28 14:20:02 +02:00
Maurice 4f98c5ea44 feat(mcp): collection membership, bulk visited marking, and upcoming bookings
Asking whether a place is already in one of your lists took a walk over
every collection and lost the coordinate tolerance the server matches on.
Marking a trip's places visited across every list holding them was one call
per place.

The dashboard's next-booking view is a three way union that folds in
synthetic check-in and check-out rows, so rebuilding it from per-trip
summaries silently drops the accommodations.
2026-08-28 14:20:02 +02:00
Maurice 9e21eb5d10 feat(mcp): note colours, note order, a note website, and category CRUD
A day note could not be coloured or positioned, so the service default of
9999 pinned every note an assistant wrote to the bottom of the timeline. A
collab note could not carry the website the card renders.

Categories could be assigned by bulk_update_places but never created: the
whole file was one read tool and a resource. The three new tools are
admin-only, matching the REST routes exactly.
2026-08-28 14:20:02 +02:00
Maurice 5d0fac3026 feat(mcp): place pictures, POI search, GPX export and a biased place search
image_url was in no places tool although the service persists it and the
picker in the UI sets a URL, which is not a file transport question.
search_place passed two of the service's four arguments, so an unbiased
query resolved to the wrong continent.

get_place_details gained expand for reviews and the editorial summary. It
defaults to off because that field mask bills as a Google Enterprise SKU,
and it now honours the admin kill switch the REST route checks: an instance
owner who turns Place Details off to stop the billing was still billable
through this one surface.
2026-08-28 14:20:02 +02:00
Maurice 922c60c167 feat(mcp): correct a journey entry, not just write one
location_name was settable when creating an entry and never afterwards,
which is the sharpest create-versus-update asymmetry in the surface.
Coordinates, weather, tags and the pros and cons verdict were persisted by
the insert but had no way in, so an entry written by an assistant never
placed on the journey map.
2026-08-28 14:20:02 +02:00
Maurice a5ac6b15e6 feat(mcp): resolve a coordinate to a region, and report the derived ones
mark_region_visited wanted a code like US-CA that nothing in the tool
surface could produce. The visited-region list also read the raw manual
marks while the UI reads the list derived from trip places, so the two
disagreed about where somebody had been. Travel stats gained the cities and
the distance the dashboard shows.
2026-08-28 14:20:02 +02:00
Maurice 4225d883c0 feat(mcp): half days, comp days, and the vacay plan settings
The toggle took a date and nothing else, then hardcoded a full vacation day
for the calling user, so a half day, a comp day or a day logged for another
plan member had no expression.

School holidays needed the plan flags that switch them on, and the region
list that makes them usable. That list needs care: a group region is stored
as COUNTRY|group:CODE and the reader treats a bare code as a subdivision, so
the tool now returns the string add_holiday_calendar actually wants. Belgium
and the Netherlands are the two countries this affects.
2026-08-28 14:20:02 +02:00
Maurice fed562d564 feat(mcp): bags, quantities and the sharing tiers of a packing item
Bag assignment was reachable only while bulk-importing a list, and by bag
name, never for an item that already exists. Quantity, weight and the
private flag were not reachable at all, and the whole three-tier sharing
model was invisible: an item made by a tool was always Common.
2026-08-28 14:20:02 +02:00
Maurice e0169388d3 feat(mcp): uneven expense splits, and the fields update was missing
Historical costs are rarely split evenly, and every budget tool took only a
list of member ids, which the service stores as a null amount, meaning an
equal share. members[] carries what each person owes instead.

A custom split has to reconcile before it is persisted, so the tool refuses
one whose shares do not add up in whole cents. Two ways round that check
needed closing with it: the write path drops a payer who is not on the trip
and then derives the total from what survived, and an empty payer list means
a total of zero rather than the previous one. Both would have certified a
split the stored row does not satisfy.

Also on update, which had none of them: currency (so the FX freeze is no
longer a no-op), expense_date, and a settlement currency, plus place_id on
create so a museum ticket can point at the museum.
2026-08-28 14:20:02 +02:00
Maurice f85a87e6d0 feat(mcp): reorder days and insert one mid-trip
An imported itinerary landed in whatever order the assistant appended it,
with no way to fix it afterwards: create_day only ever appended, and
days.service.insert was reachable from exactly one place in the server, the
REST controller. Because both halves were missing, inserting a day into the
middle of a trip could not be expressed at all.
2026-08-28 14:20:02 +02:00
Maurice f1bbd94f2b feat(mcp): booking link and end time on a reservation
url is persisted on create and update and rendered as a link by the
planner, and reservation_end_time is what makes a dinner run 19:00-21:30
rather than just starting. Neither was in a tool schema, so a booking
written by an assistant lost both - which for an imported itinerary is
every confirmation link it came with.

url validates through reservationUrlSchema from @trek/shared, the same
refinement the REST route applies, rather than a second hand-written one.
2026-08-28 14:20:02 +02:00
Maurice aa5002b2ed fix(mcp): keep transit out of the transport create enum
The transport form's picker has nine entries, not ten: a transit booking
carries a provider itinerary in metadata.transit and create_transit_journey
is what writes one, so letting a caller hand-make a bare transit row
produces a shape the transit UI does not expect.

Splits the one list in two - what may be asked for, and what counts as a
transport for the update gate - so a stored transit booking stays editable
through update_transport, which is where it was already reachable.

Also reports the ids set_reservation_travelers dropped instead of
returning a plain success with a shorter list.
2026-08-28 14:20:02 +02:00
Maurice 86c0c197db feat(mcp): set who is travelling on a booking
Reads have hydrated a booking's travellers since #1517; there was no way
to write them, so an imported booking could not record who was on it -
and creating a guest is only half an answer if the guest cannot then be
attached to the flight they were on.

Mirrors PUT /api/trips/:tripId/reservations/:id/travelers. The roster
filter stays in the service, so an off-trip id is dropped rather than
attached and the tool cannot be used to enumerate users.
2026-08-28 14:20:02 +02:00
Maurice 4b21a70ab2 feat(mcp): add, rename and delete trip guests
add_trip_member resolves an existing account by username or email, so a
travelling companion who never signed up could be put on a trip in the
planner and not through an assistant. A guest is assignable to budget
splits, packing, to-dos and day participants like any member, so leaving
them out of an import loses who was actually there.

Mirrors the owner-only /api/trips/:id/guests routes, including the
trip-scoped guard that keeps one trip's owner away from another trip's
guest and off a real member's user row.
2026-08-28 14:20:02 +02:00
Maurice 34522ad3bc fix(mcp): accept every transport type the planner offers
create_transport and update_transport took flight, train, car and cruise.
The picker in TransportModal has ten modes and ReservationsPanel renders
all of them, so a bus, a ferry or a taxi could be planned in the UI and
not through an assistant - which is where importing a travel history from
another service runs aground.

Both lists now come from one constant in the picker's order rather than
four literals repeated across two schemas, which is how they drifted.
legs[] stays on flight and train: the form writes metadata.legs for those
two and for nothing else.
2026-08-28 14:20:02 +02:00
jubnl d8fd2deb12 feat(sdk): allow new signing key (#2088) 2026-08-28 00:56:44 +02:00
Maurice 922f37ab52 fix(storage): ask the hook whether a storage job is running, not a stale closure
Every push to dev has been red since the 4.0.0 bump, always on the same test:
FE-MOB-MSTOR-015, where the second queued migration POSTs while the first is
still running. It passes on the PR runner and fails on the slower push one.

Instrumenting the queue effect logs the whole story:

  DEQUEUE files stateMigrations []
  APPLY refresh ["running"]
  START RESOLVED files error null
  DEQUEUE journey stateMigrations []

The refresh had already landed the running migration before the single-flight
lock was released, exactly as intended. What fires afterwards is the effect of
a commit scheduled BEFORE the POST, so its captured admin.state is the pre-POST
world, and it runs late enough that the lock is gone. The lock is a ref and is
read live; the "is one already running" test was read off that dead closure.
Under load the two drift apart and a second migration starts on top of a
running one.

So the hook answers the question instead. It keeps its world in a ref as well,
written where the response is applied rather than when the render lands, and
storageBusy() reads that. The queue effect still WAKES on admin.state, it just
no longer DECIDES on it, and the server's one-job-at-a-time rule now lives with
the hook that talks to the server rather than twice in two shells. Same shape as
the dirtyRef/savingRef the hook already keeps for the same reason.

An earlier attempt made the panels refresh again after startMigration, which was
both redundant (startMigration has awaited its own refresh since #1949) and no
fix at all: the stale read is on the next effect run, not on that promise.
2026-08-28 00:22:57 +02:00
Maurice f513881c01 ci: pin the platform order in the multi-arch manifest
The manifest list was assembled from a glob over the downloaded digest files,
which are named after the digest itself. The order was therefore alphabetical
by hash, and it changed from one release to the next: 3.4.1 lists amd64 first,
4.0.0 lists arm64 first.

That is not supposed to matter — a client is meant to pick the entry matching
its own platform — but several people updating from 3.4.1 to 4.0.0 on amd64
reported that `:latest` still resolved to 3.4.1 and only a pinned `:4` or
`:4.0.0` pulled the new image. Unraid came up twice. Content, format and digest
of the three tags are identical, so the flipped order is the only thing that
changed between the release that worked and the one that did not.

The digests are now collected per platform in a fixed order, amd64 first. That
needs the artifacts unmerged, since the per-platform folder name is the only
thing that says which digest belongs to which architecture.

Second thing the loop fixes: the glob happily built a single-platform manifest
if one of the two builds had failed to upload. It now fails loudly instead of
publishing a `:latest` that half the users cannot pull.
2026-08-27 23:28:35 +02:00
Maurice 92575c0d49 @
fix(import): stop reading tickets as lodging, and stop scroll escaping a dropdown

Two v4.0.0 reports.

The file importer classified nearly every transport document as a hotel. Its
keyword table tested lodging second, on a group that included "check-in" and
"check-out" — words printed on almost every ferry, train and bus ticket there
is — so a ferry ticket came back a hotel, and the review then handed the user
the booking form, whose six chips contain no transport type at all. That is
where "everything ends up as other" comes from: it was the only honest pick
left. Transport is tested first now, the lodging pattern asks for a word that
names lodging, and the rental pattern asks for a rental rather than for a
pick-up time that ferries and shuttles also print.

Two more in the same function. The flight path forces its type on everything it
returns, and it claimed any document with two letters followed by digits — which
is what a German rail number looks like, so an IC ticket was imported as a
flight without the keyword table ever running. It now stands down when the
document already names another kind of transport. And a type neither form can
express opened the booking form regardless; it now follows the tab the import
was started from, while a type either form knows still wins over the tab, since
one PDF routinely holds a flight and a hotel. An unreadable type reaching the
transport form arrives as transport_other rather than as a flight: a wrong
flight looks right enough to be saved unnoticed.

The other report: a dropdown inside a sheet scrolled the page instead of its own
list. The panel is portaled to document.body and positioned fixed, so its scroll
chain runs to the viewport rather than to the sheet it looks like it belongs to,
and nothing stopped it. Three portaled lists carry overscroll-behavior: contain
now — the select, the navigation menu (which reaches the phone through the place
sheet, not only the desktop inspector) and the phone day picker.
@
2026-08-27 21:35:03 +02:00
Maurice 4d6820a4d4 @
fix(planner): route a layover flight per leg, and count linked places as planned

Two v4.0.0 reports, both a surface answering a question with less than it had.

The map built its own waypoint order. It read the BOOKING position and never
expanded metadata.legs, while a multi-leg booking stores its position per leg —
so a Frankfurt to Vancouver flight via Reykjavik was dropped from the list and
the airport before it was joined to the airport after it in one driving run
across the Atlantic. It now takes the order from getMergedItems, the same one
the day plan renders, which brings the span filter and the leg expansion with
it. getMergedItems in turn had never read day_plan_position, the one slot the
map did read, which is how the two could disagree at all.

getTransportRouteEndpoints handed back the booking outermost airports for every
leg. A stop between two legs was therefore routed from the arrival airport back
to the departure one — a drive from Vancouver to Frankfurt with a cafe in
Reykjavik on the way. A leg now spans its own pair, when the endpoints line up
one per stop with the legs; anything ambiguous keeps the role lookup.

Being planned meant one thing: a day_assignments row. A hotel is linked through
its stay and a venue through its booking, and neither is ever dragged onto a
day, so both sat under Unplanned while the day header printed their names. The
three places that asked the question — desktop sidebar, map markers, phone
browser — now ask one helper that also counts a stay and a day-anchored
booking. A booking that names no day still counts as unplanned, because nothing
says when it happens, and the day plan renders nothing for it either.
@
2026-08-27 19:21:06 +02:00
Maurice 88c34f6415 @
test(calendar): cover the window paths the split added

The domain branch ratchet went from 93 to 92.3: the new hand-over code brought
branches with no case behind them. Three cases and one simplification, no
threshold touched.

Covered: a split rental whose endpoints carry no IANA zone and fall back to
their coordinates; a bare end clock, which resolves against the start date and
therefore does NOT split; and an unsplit rental with a day but no clock, whose
hand-over has nothing to place and correctly emits nothing.

The metadata parse now happens once, inside describeReservation, rather than
three times. The two extra copies were only there to hand `meta` to it, and one
of their arms is unreachable from SQLite, where the column is always text.
@
2026-08-27 18:19:33 +02:00
Maurice bbe4d62c90 @
fix(export): print the reader clock format, and split window bookings in the feed

Three v4.0.0 reports that all come down to a surface printing what is stored
instead of what the reader asked for.

The PDF (#2066) is assembled as an HTML string outside React, so no hook could
reach it and no time format was ever passed in. It printed the column: four
clock sites, all 24h whatever the personal setting or the admin default said,
and a place time typed by a 12h user came out as "3:00 PM" for a 24h reader.
Both export entry points now hand the format over the way they already hand
over the locale, and the four sites go through formatClockTime.

The journey editors (#2067) used a native <input type="time">, which paints
12h or 24h from the browser locale and cannot be told otherwise by any
attribute. Every other time field in TREK has used CustomTimePicker for exactly
that reason; the journey editor, its phone sheet and the phone place sheet were
never migrated, so the same place form obeyed the setting on desktop and
ignored it on a phone. The picker gained a disabled prop so the read-only
journey sheet keeps the affordance the native input gave it.

In the subscribed calendar (#2068), a booking that is a window rather than an
activity read as one block across its whole range: a week of airport parking
sat on all seven days. Both hand-overs now stand on their own — an hour each,
carrying what the block used to say — and the block goes. Only when the two
ends fall on different days: a same-day booking is one sitting, and a rental
with only its pickup geocoded keeps the block, which is the only thing holding
the return time and its zone at all. Rental cars were missing outright, because
the planner writes reservation_time = NULL when its optional time pickers are
left blank while still setting day_id, and nothing in the exporter ever read
the linked days. It reads them now, as the last resort, for every type — so a
hand-typed bus or train shows up as well.
@
2026-08-27 18:19:33 +02:00
Maurice eb3e0ca7c9 Merge pull request #2065 from liketrek/feat/upcoming-stays-and-status
fix(dashboard): fix the upcoming widget dropping bookings, and give it the stays back
2026-08-27 16:20:26 +02:00
Maurice 0ca4dbaa94 Merge pull request #2069 from liketrek/fix/tests-pinned-to-3x
test(server): unpin three fixtures from the 3.x version window
2026-08-27 16:20:14 +02:00
Maurice a9b0cccb74 @
test(server): unpin three fixtures from the 3.x version window

The 4.0.0 bump turned three suites red on dev, all for the same reason: they
described a world where the host was still 3.x.

The plugin install fixtures declared trek ">=3.2.0 <4.0.0" and ">=3.0.0
<4.0.0", so every default artifact became incompatible with its own host and
23 tests failed on the version gate instead of on what they were testing. The
upper bound bought nothing — the cases that exercise the gate pass their own
range — so it is gone rather than moved to 5.0.0.

The system-notice suite named thank-you-support twice. It is capped at 4.0.0
and handed over to release-4-0-0 there, so the established-user case saw a
notice it had never been told about, and the per-version recurrence case rode
on a notice that no longer applies at all. The first now reads the always-on
set out of the registry instead of naming it, and the second drives the
mechanism through a notice it pushes itself. Neither ages out at the next
release.
@
2026-08-27 16:14:25 +02:00
Maurice f15a124016 @
fix(dashboard): fix the upcoming widget dropping bookings, and give it the stays back

Three things, all in the same widget.

reservation_time holds two shapes. The booking form writes the datetime-local
YYYY-MM-DDTHH:MM; day-anchored rows, the demo seed among them, hold a bare
HH:MM. The query compared the column against an ISO timestamp as a string,
where ":" sorts above "2": every booking from 20:00 on passed the filter and
everything before it silently vanished, and the order ran off the same column.
On the demo that is why the Broadway show shows and the ferry does not. The
query now separates the date from the time before it filters, so a value with
no date falls back to the day it hangs off instead of being read as one, and
the order is by day and clock. The demo seed writes full timestamps, which is
the shape every real write path already produces.

Hotels came out of this list because a stay covers a range and a week in one
would hold a slot for the whole week. Arriving and leaving do not: they are
moments, and they are the two worth reminding someone of. Check-in and
check-out now appear as their own entries, named after the stay place and
falling back to the linked booking. The stay itself stays out. The two carry
the accommodation id, so the list key had to take the type with it.

An unconfirmed booking looked exactly like a confirmed one. The status was
already in the payload and is now a badge, on both the desktop tool and the
phone widget.
@
2026-08-27 15:38:45 +02:00
Maurice 99387812a1 Merge pull request #2063 from liketrek/main
chore: back-merge the 4.0.0 version bump into dev
2026-08-27 13:32:21 +02:00
github-actions[bot] a00b84b216 chore: bump version to 4.0.0 [skip ci] 2026-08-27 11:23:54 +00:00
jubnl 77dddd5e8a v4.0.0 (#1719)
Release branch for 4.0.0.
2026-08-27 13:22:39 +02:00
Maurice 4784a1e5dd fix(quality): clear the SonarQube findings on the 4.0.0 branch (#2062)
The day-swipe move handler mixed classifying the gesture with following the
finger, which put it at cognitive complexity 20. The classifying half is a
pure question about a gesture, so it moves out as classifyLock: null keeps
waiting for the next move, dead and h end the same way they did before.

Also reads the reduce-motion flag through dataset, seeds the closest-year
reduce with years[0] so it cannot throw if the empty guard above it ever
loosens, and compares the in-flight preview entry against undefined instead
of leaning on a promise being truthy.
2026-08-27 12:38:14 +02:00
Maurice de9bd94279 @ (#2061)
fix(plugin-sdk): stop the README heading match backtracking

`missingSections()` matched headings with `(.+?)\s*(?:<!--.*)?$`, where the
lazy body and the trailing `\s*` compete over the same run of spaces. That
backtracks quadratically: a heading line padded with 80k spaces takes about
12 seconds, 200k about 75. The gate runs over READMEs anyone can submit to
the registry, so the padding is not the author own input.

The trailing anchor comment is now cut with indexOf, and the body is matched
with its edges pinned to non-space, so nothing competes with the trailing
`\s*`. Same shape the wiki sidebar parser was rewritten to for the same
reason. Behaviour is unchanged on every heading that is not the attack:
trailing comment, unterminated comment, tab separator, padded title.

scripts/check-readme.mjs in the TREK-Plugins registry carries the same
regex and needs the same change.
@
2026-08-27 12:34:55 +02:00
Maurice 1f66ef1d32 fix(maps): let users and admins enter a CARTO API key (#2060)
CARTO stamps an "API KEY REQUIRED" watermark onto every basemap tile
fetched without a key since 26.08.2026, and both CartoDB presets plus the
built-in fallback are CARTO. The key is free and needs no account, so add
it as a setting rather than dropping the provider.

carto_api_key sits next to mapbox_access_token: per user in Settings/Map,
instance-wide under Admin/User Defaults, encrypted at rest, injected from
CARTO_API_KEY on managed instances. Unlike Mapbox it never switches the
map provider, since a raster key says nothing about the renderer.

The key is appended as ?key= at render time instead of being stored in the
template, so a saved URL stays portable and survives a rotation. That is
also what reaches the four maps that pinned CARTO and ignored the user's
tile setting: atlas, collections, the public trip share and the public
journey. The two share pages read it from their token-gated payload, the
same way baseCurrency already travels.

Also lifts the release notice above page chrome. Its overlay sat at
z-index 50, under the Vacay mode toolbar at 61, so the dialog was neither
on top nor modal. Adds a six-step layer scale to index.css and moves the
three notice shells onto it.

Closes #2054
Closes #2052
2026-08-27 12:06:55 +02:00
Maurice 521e52c853 fix(collab): harden the link preview fetcher (#2058)
The preview endpoint takes a URL from whoever is typing in a trip chat and
fetches it from the server. Trip access was added to it earlier; this is the
rest of what that route needed.

The SSRF guard had a hole under it. `isAlwaysBlocked` knew `::1` and `0.0.0.0`
but not the IPv6 unspecified address: `::` starts with a colon, so the `0.`
check never saw it, and connecting to it lands on loopback. `http://[::]:9200/`
therefore passed the guard and came back with the target's title and og: tags
scraped out of the body — an exfiltrating read of anything listening locally,
from any account. `[::0]`, `[0:0:0:0:0:0:0:0]` and `[::ffff:0:0]` are the same
address in other spellings and behaved the same way. The two `::ffff:`-prefix
regexes had the same shape of gap: they matched the dotted spelling of loopback
and link-local, so `::ffff:7f00:1` walked past them. Both checks now decide on
the expanded hextets, which covers every spelling at once, and fe80::/10 is
read as ten bits rather than as the four characters 'fe80'. This is in
ssrfGuard, so every caller gets it, not just collab.

The scrape could be turned into a denial of service. Each og: regex had two
unbounded `[^>]*` runs separated by a literal, so a document with no `>` in it
made the engine rescan the remainder from every `<meta` it passed — quadratic,
and the byte cap admits half a megabyte. Measured on this build: 240KB of
'<meta ' took 58 seconds, and Node has one thread, so that is the entire
server, health check included, on one request. Bounding the runs at 512
characters makes it linear (540KB now takes 1.4s); no real attribute list comes
close to that.

The five-second timeout only covered the headers — `clearTimeout` ran before
the body was read — so a server that answered fast and then dripped one byte
every few seconds held the handler, the socket and the per-request undici agent
open indefinitely. The byte cap counts bytes and at that rate would never reach
one. `AbortSignal.timeout` covers both halves, the agent is closed in a
`finally`, and the three bail-outs that used to return without touching the
body now discard it, which is what cappedFetch's helper is there for.

The rest is smaller. Nothing metered the fetches, so an account with a trip of
its own could drive arbitrary outbound requests in a loop; there is a budget of
sixty new fetches a minute per user now, charged per outbound fetch rather than
per request. Previews are cached for ten minutes and simultaneous askers for
one URL share a single fetch, so the client's habit of requesting a preview per
rendered message — up to a hundred at once, with no debounce — costs one fetch
per new link rather than one per render. Refusals answer with one constant
reason instead of the guard's own: it distinguishes "could not resolve" from
"private address", which together map out the server's internal DNS for anyone
willing to guess hostnames. A body whose declared type is not markup is dropped
rather than combed for og: tags, and an og:image that is not an http(s) URL is
not passed to the client, which puts it straight into an <img src>.

Deliberately no `@RequirePermission` on the route: the client asks for a
preview while rendering a message or a note, never while composing one, so it
belongs with the GET siblings. Requiring collab_edit would blank every preview
for members of a trip whose owner narrowed that right while leaving them able
to read the chat, and it would not stop the abuse it looks like it addresses —
the permission check passes for a trip's owner at every level, and anyone can
create a trip.
2026-08-27 12:03:04 +02:00
Maurice 800f97b8ce feat(mobile): swipe the day details to step through days (#2059)
* @
feat(mobile): swipe the day details to step through days

The phone shell hangs the days off a chip rail pinned to the top of the
screen, which on a large phone is a thin target a thumb cannot reach one
handed. The day details fill the rest of the screen and did nothing.

They now take a horizontal swipe: left for the next day, right for the
previous one, with the panel following the finger, rubber banding at the
first and last day and springing back when the swipe falls short. The
chip rail is untouched and still the accessible path; a committed swipe
announces the day it reached through a live region, and the active chip
scrolls itself back into view once the rail overflows.

The gesture never calls preventDefault or stops propagation on a touch
event and sets no touch-action, so the browser keeps sole ownership of
the card scroll and of the header chip strip, and the long press reorder
bridge keeps its own 10px self cancel. In edit mode the swipe also
refuses to claim a press held past the bridge window.
@

* @
fix(mobile): line the map controls up and widen the POI bar

The compass sat on its own under the POI bar, centre top, while the
locate button floated 42px above the dock on the other end of the
screen. Both are one handed controls, so they now share the band just
above the dock: compass bottom left, locate bottom right, off the same
--bottom-nav-h the map already reads, a dock gap lower than before.

That frees the row under the chip rail, so the nearby places bar takes
the full width between the screen margins and its segments end up the
size of everything else the thumb aims at there. Desktop keeps the
floating, content width bar.
@
2026-08-27 12:01:11 +02:00
Maurice 495c5e95ec fix(mobile): drop stale up-next cards and open the plan on the next day (#2057)
The UP NEXT card kept pointing at this morning's first stop hours after it had
started, and at day one of a trip that ended last month. A header promising what
comes next, sitting above something that is over. It now stays away on a day that
has passed and once today's timed plan has run out — a day whose stops carry no
times at all still shows its first stop, there is nothing stale about that. The
timeline card already collapses the reserved space when there is no up-next, so
the layout closes cleanly.

Seeding the plan day also lands on the next day still ahead when today falls into
a gap in the dates, instead of dropping back to day one. A trip that is already
over keeps opening on its first day.

And a day that was selected before the shell mounted now counts as seeded, so
clearing the selection is left alone instead of being re-seeded on the next
render — which is what the guard was there for in the first place.

Based on #2055.
2026-08-27 11:02:09 +02:00
Maurice f61bab3ffd fix(vacay): open on the current year instead of the last one added (#2056)
The year list comes back sorted, so picking its last element meant Vacay always
landed on the highest year — add 2030 to plan ahead and every visit from then on
opened 2030. It now opens the leave-year period today falls into, falls back to
the closest year the plan has when that period has none, and keeps the year the
viewer is on when a live update reloads the list.
2026-08-27 10:55:27 +02:00
jubnl d194d2cf6f chore(workflows): paths target on push 2026-08-26 17:12:12 +02:00
jubnl 1a126fb6fc chore(readme): add star history 2026-08-26 16:36:34 +02:00
Danette10 f605b730d8 fix(journey): improve video thumbnails and playback layout (#2043)
* Revert "chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)" (#1694)

This reverts commit 94736112cf.

* feat: add PhotoGrid and PhotoImg components for journey photo gallery display

* feat: implement JourneyPublicPage to display shared journey data with timeline, map, and gallery views

* feat: create MobileEntryView component for displaying detailed journey entries on mobile devices

* feat: add docker-compose configuration for video testing environment

* feat: add gallery view components with support for local photo/video uploads and external provider integration

* fix: green up the video layout branch

Four things stood between this and a merge.

index.css carried committed merge conflict markers. A format-on-save had run
over the whole file and collapsed `=======` and `>>>>>>>` onto the line that
starts the popup-tip rule, which makes .trek-map-popup .mapboxgl-popup-tip part
of a malformed selector list - so the parser drops the whole rule and the map
popup's tail comes back as the stray notch its comment describes. Nothing in CI
catches that; invalid CSS just disappears. The file is back to the dev version
with only the video block added, 1301 -> 1323 lines instead of 2057, and the
same for the lockfile, which had only picked up "peer": true annotations from a
different npm run.

PhotoImg referenced an onClick it never took as a prop - the click already sits
on the button wrapping it.

PublicPhoto did not declare media_type even though journey-share.service.ts
selects it in both queries, which is what the `as any` casts around it were
working around. Declared once, the way JourneyPhoto does, and the casts are gone.

The single-photo hero had moved to a thumbnail. Right for a video, whose
/original is the video file and leaves an <img> blank - but a photo hero is
deliberately full size, which FE-JRN-PUBWIRE-008 pins. It now picks per media
type.

---------

Co-authored-by: jubnl <66769052+jubnl@users.noreply.github.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-08-25 23:42:11 +02:00
jubnl 5b9aaab847 fix(client): mint upload idempotency keys via randomId()
uploadQueue called crypto.randomUUID() bare, which is undefined on
non-secure origins (plain-http localhost and LAN installs) and threw
instead of uploading. Route it through randomId() like the other two
idempotency-key producers so it falls back to getRandomValues, and
update randomId's header comment to name all three producers.
2026-08-25 23:16:18 +02:00
jubnl 0269273d01 fix(budget): use Math.trunc for user colour index, complete settlement fixtures
The `| 0` truncation in colorForUserId/colorFor existed to coerce an
undefined user_id to 0, but the settlement API always returns user_id —
only the two BudgetPanel test fixtures omitted it. Add user_id to the
fixtures, switch both helpers to Math.trunc (with a Number.isFinite
guard in colorForUserId preserving the old undefined->0 behaviour), and
drop nothing else: TravelerPicker only ever receives required numbers.
2026-08-25 23:16:12 +02:00
jubnl f5bfc321d7 chore(sonar): keep only categorical issue suppressions
Instance-specific findings (S2245 singles, S4790, S5332, S2819, S7767,
S4158 sessionManager, S8786, S6825) are resolved server-side as false
positive / accepted / reviewed-safe instead, so future violations of the
same rule in the same file still surface. Narrow the i18n secrets scope
to the locale directories so top-level code files stay fully scanned,
and drop the comment claims the code contradicts.
2026-08-25 23:16:05 +02:00
Maurice 105cb68316 fix(mobile): text fields no longer show a dark box inside their row
iOS paints its own background inside a text control, and under color-scheme:
dark that fill is darker than the card the field sits on. The mobile fields
are deliberately bg-transparent because the row supplies the surface - which
is why the native fill has nothing covering it and shows through as a dark
rectangle, while a desktop field hides it behind its own background.

Closes #2028
2026-08-25 23:02:13 +02:00
Maurice f052d9d26b docs: the storage seed example lives in the wiki now
Admin-Storage pointed at the README's Storage section for the seed file
example and the compose mount line. That section is gone, so the link went
nowhere and the example existed in no copy at all - the wiki page described
storage-config.json without ever showing one. Both are on the page now.

The commented mount in docker-compose.yml pointed at the same removed
section.
2026-08-25 22:54:54 +02:00
jubnl 167e2e9428 chore(readme): remove last part related to storage 2026-08-25 22:51:41 +02:00
Maurice ce584da168 docs: same spacing above every top-level heading in the README
Three headings sat directly on the paragraph above them while every other one
is preceded by a <br />. Sub-headings keep following their prose directly, as
they did before.
2026-08-25 22:49:13 +02:00
Maurice c563159072 docs: point the README at the wiki and the compose file it duplicates
Three more sections that only restated what is maintained elsewhere, and had
started to drift: the embedded compose example still pinned tmpfs at 64m where
docker-compose.yml has said 128m for a while, and it was missing the proxy,
language and kitinerary options the real file documents inline.

The headings stay, so the in-page nav link to #docker-compose-production and
any external link into these anchors still land.
2026-08-25 22:49:13 +02:00
Maurice 8087919d31 Remove storage configuration details from README
Removed detailed storage configuration and provisioning instructions from the README.
2026-08-25 22:49:13 +02:00
Maurice b597d71836 Fix formatting issues in README.md 2026-08-25 22:34:42 +02:00
jubnl 9bbbef6279 chore(workflows) add concurrency with cancel in progress 2026-08-25 22:23:56 +02:00
jubnl d4ecf96b64 chore(readme): add build status 2026-08-25 22:21:30 +02:00
jubnl b58515e47e chore(workflow): test workflow how now concurrency key 2026-08-25 22:15:00 +02:00
Maurice c435033343 refactor: one endpoint-name cleaner for both map renderers
The scan that replaced the parenthetical-stripping regex went into the Leaflet
overlay and its Mapbox GL counterpart as two byte-identical copies, which is
what the duplication gate caught. trackColors.ts next door exists for exactly
this reason - both renderers read a track's colour through one function so it
cannot come out different on one map than the other - so the cleaner follows it.

Behaviour is unchanged. The test brute-forces the new function against the regex
it replaced over all 55,987 names up to six characters of "ab() \t", and pins the
32k-space input that used to take a second.
2026-08-25 22:08:32 +02:00
Maurice a3ad53b3bf fix: close the backtracking patterns SonarCloud's profile turns up
The move to SonarCloud brought a wider rule set with it, and 23 more regexes
that can be made to hang on a crafted near-match. 21 are fixed here, each proven
equivalent to the pattern it replaces by brute force rather than by reading.

Two of them are reachable across users, not just by yourself. A reservation
endpoint name is free text any trip member types, stored and then read back in
every other member's browser while the map draws its labels, and the pattern
over it takes 2.3 seconds on a 32k paste - so one member could freeze the map
for the whole trip. The mobile transport sheet reads the same field. Both now
scan instead of backtrack, and both were matched against the desktop twin the
earlier sweep had already fixed rather than given a third spelling.

The markdown link stripper needed the same treatment as the tag stripper before
it: the label class cannot be disambiguated in the pattern, because it matches
an opening bracket as well, and narrowing it changes what "[a[b](c)" produces.
It is a scan now, with all four of the regex's quirks intact.

Also here: the two encoding warnings the scanner reported. A decorative comment
line in JourneyDetailPage.test.tsx had picked up a replacement character
somewhere along the way, and calendar.service.test.ts asserts on one
deliberately - that one is now written as a U+FFFD escape, so the file no longer
carries the raw bytes while the test says exactly what it said before.

Seven findings were deliberately not changed and are flagged in SonarCloud with
the reasoning. Five are false positives - patterns with no overlapping
quantifier pair, where the only cure would change how nested brackets parse.
Two are marked accepted rather than dismissed, because they are real: the readme
check's heading pattern has a proven linear replacement that cannot land until
the registry's copy of the same script can move with it, and
`export let JWT_SECRET` is what makes an admin's key rotation take effect
in-process instead of silently waiting for a restart.
2026-08-25 22:08:32 +02:00
jubnl b98363b2b0 chore(workflow): single workflow that runs the test AND the sonar gate (#2046) 2026-08-25 21:54:31 +02:00
jubnl 981a634dfb chore(sonar): switch to cloud 2026-08-25 19:42:45 +02:00
Maurice 62b13a278e refactor(common): one shared comparator instead of three inline copies
The coverage ratchet caught something worth keeping: an inline
`(a, b) => (a < b ? -1 : a > b ? 1 : 0)` is a new function with four branches at
every call site, and two of the three in src/nest/common sit on error paths that
a passing test never walks. Three restatements of the default sort order were
enough to pull the domain under its pinned thresholds (functions 97.8% against
99, branches 87.92% against 89).

Writing tests to walk those error paths would have been solving the wrong
problem. byCodeUnit says the same thing once, is covered once, and the callers
read better for it.

Its test asserts equality with a bare `.sort()` rather than hand-written
expectations, since "identical to the default order" is the whole contract. It
also pins what byCodeUnit is NOT: `['Zelt', 'Übernachtung']` keeps that order,
where localeCompare swaps it — so the next person reaching for a comparator on a
list a human reads picks the other one.
2026-08-25 19:26:38 +02:00
Maurice 071f516c0f refactor(llm-parse): use the shared tag stripper instead of a second copy
Two passes over the ReDoS findings arrived at the same scanner independently —
one replacing `/<[^>]+>/g` in the KML importer and the Commons metadata helper,
the other in the mail/HTML text extractor. The algorithm is the same down to the
edge cases, so the extractor now imports common/stripHtmlTags rather than
carrying its own.

Checked before folding them together rather than assumed: the two
implementations were compared over 2,441,406 generated strings and agree on
every one. The shared helper takes the replacement string as a parameter, which
is the only thing that differed — the extractor substitutes a space, the KML
importer nothing.

Two cases the shared helper's own test covers only in the abstract get a
concrete one here, since this is where the input is a file somebody uploaded: an
empty `<>` stays literal, and a `<` inside a tag is content, so
`3 < 4 and <b>5</b>` strips to `3 5` rather than keeping the first bracket. Both
are the old regex's behaviour, which is the point.
2026-08-25 19:26:38 +02:00
Maurice 1ad2e92142 fix: make the app keyboard-reachable, and close four DoS-able patterns
The bulk of what SonarQube still had open on the reliability side, read site by
site rather than swept. Two clusters are worth calling out because they are
defects, not lint.

**Four regexes could be made to hang the process.** The worst is
`/.+@.+\..+/` in the forgot-password path: three unbounded `.+` that all
overlap, unanchored, on an endpoint that needs no login and whose schema is a
bare z.string() under a 100kb body cap. A thousand '@' characters had not
finished after 100 seconds — one request pins the event loop, and Node has one.
EMAIL_REGEX in auth.helpers is the same shape on register and profile update
(310ms for 8kb). The two tag strippers over an uploaded KML description and over
Commons metadata took 0.52s on a 16k run of '<'. Each rewrite was proven
equivalent by brute force against the original — millions of strings for the
coordinate patterns, 671,876 for the tag stripper — because a subtly different
regex on a parsing path is worse than the finding.

`<[^>]+>` could not be fixed in the pattern: `[^>]` matches `<` too, so the only
unambiguous form changes what happens to malformed markup. It is now a scan in
common/stripHtmlTags.ts, quirks included — `<>` is left alone, `<a<b>` is one
tag — with the equivalence pinned by a test that compares against the old regex
over every string up to length six.

**And roughly 500 places could only be reached with a mouse.** Clickable divs
became real buttons where the markup allowed (52 of them), took
role + tabIndex + Enter/Space where they had to stay divs, and modal backdrops
took role="presentation" — the honest role, since a backdrop is a mouse shortcut
for a Cancel control that is already in the tab order, and making it a button
plants a nameless full-screen tab stop around its own contents.

Alongside those: `.sort()` calls that now say the order they always had, `{n &&
<X/>}` guards that rendered a literal "0" for a zero-byte file and for a place at
latitude 0, an optimistic budget write that stored a boolean where the contract
and both transports use 0/1, and the Number statics.

sonar-project.properties documents the six that were deliberately left: two wiki
patterns with no overlapping quantifier pair whose "fix" would stop parsing
`[see [1]](url)`, four tar header fields bounded at 12-155 bytes by the format,
two djb2 hashes where the 32-bit wrap is the algorithm, two `| 0` that are also
coercing undefined to 0 on a path where the API sends no user_id, and the MCP
session maps Sonar reads as empty because they are filled from another module.
2026-08-25 19:26:38 +02:00
Maurice 52d1a0720f fix: say out loud what every sort was already doing
Sonar's S2871 flags a bare .sort(), which orders by UTF-16 code unit rather than
by value. All 30 remaining sites turned out to sort ASCII strings — ISO dates,
currency and country codes, setting keys, route names, OAuth scopes, zip entry
names — where that order is already the intended one. Each says so explicitly
now, with a comparator that reproduces it exactly rather than a localeCompare
that would quietly reorder things.

That mattered more than it sounds. Several of these feed something other than a
display: useAtlas joins the sorted codes into the signature that decides whether
to rebuild the region layer, daysSlice re-pins sorted dates positionally onto
reordered days, and three timeline views pick a day's colour by indexOf into the
sorted array. Switching any of them to a locale collator would have been a silent
regression.

Two smaller ones came with it. The .map(placeBtn) calls in FileManagerAssignModal
pass the index as a second argument, which placeBtn declares and uses for its
React key — correct already, now written out as .map((p, i) => placeBtn(p, i)).

And budgetSlice's optimistic paid-toggle stored the raw boolean behind
`paid as unknown as number`, while the schema declares z.number() and both the
REST response and the WebSocket event carry 0/1. So the optimistic state and the
confirmed state disagreed on type until a refresh. It normalises now, and the two
tests that asserted `true` assert the contract.

Four `| 0` truncations are listed in sonar-project.properties instead of changed.
Two are a djb2 string hash where the 32-bit wrap is the algorithm. The other two
look like plain truncation of a row id, but `| 0` is also coercing undefined to
0 there — the settlement flows the API returns carry no user_id, so that is the
normal path, and Math.trunc returns NaN and crashes the lookup.
2026-08-25 19:26:38 +02:00
jubnl 4ba407aead feat(plugins): install an older version of a plugin (#2035)
Adds a version picker to the plugin admin panel, on desktop and mobile: every
published version with its own server-computed compatibility verdict, so the
client never re-derives semver range logic.

A deliberate non-latest pick sets an update hold on the row, which keeps the
update banner and "Update all" from immediately nagging the rollback away. The
hold releases when an install lands back on the newest compatible version by any
path, or when the admin resumes updates explicitly. An unresolvable registry
never sets one: refusing to pin on missing information beats silently muting
future updates.
2026-08-25 17:30:27 +02:00
cartoone b5c8182410 feat(planner): clicking the selected day header again deselects it
Same effect as the day panel's close button: selection cleared and the
day detail panel closed. Discussed in #2024.
2026-08-25 17:26:09 +02:00
cartoone 1cb67d5e64 feat(planner): scope the planned places filter to the selected day
With a day selected, the 'planned' filter now shows only that day's stops,
matching how the other filters follow the selection; with no day selected
it keeps showing the whole plan. 'unplanned' still uses the whole-trip
assigned set. Discussed in #2024.
2026-08-25 17:26:09 +02:00
Maurice fe5c6f182e fix(ci): restore the peer dep the swc install drops, and close a test race (#2040)
Two separate reasons v4.0.0 (#1719) is red.

The SonarQube workflow killed all 616 client test files with "Cannot find module
'@testing-library/dom'". That package is a peer dependency of
@testing-library/react and not a direct dependency of the client, and the step
that installs @swc/core's Linux binary passes --legacy-peer-deps, which tells npm
not to put peer dependencies back when it re-resolves the tree. In test.yml that
step lives in the server job, which never runs client tests, so folding the jobs
into one (#2039) is what brought the two together. It now installs alongside the
swc binary, with its version read from the peer range rather than pinned, and a
one-line resolve check right after fails fast with the module name instead of six
hundred identical failures twenty minutes later.

MAdminStoragePanel's FE-MOB-MSTOR-015 was a genuine race in the test, not in the
panel. Its POST handler incremented postCount, then awaited request.json(), and
only then pushed to migrationPosts — so between the two there was a window where
one POST had been counted but migrationPosts was still empty. The GET handler
reports `migrations: []` for an empty migrationPosts, i.e. "nothing running", so
a poll landing in that window told the queue it was free to start the second
migration, and the waitFor(postCount === 1) above could resolve inside it too.
Parsing before counting closes it. Verified by running the whole mobile suite at
--maxWorkers=8, where it used to surface.
2026-08-25 17:09:29 +02:00
Maurice 6fe5009c20 ci(sonar): fold the four coverage jobs into one, on the root scripts (#2039)
Review feedback on #2038: the root already has a script that does all of this,
so the five jobs and the artifact shuffling between them were doing by hand what
`npm run test:cov` does on its own. It ends with `--prefix plugin-sdk`, so all
four packages are covered, and each writes its lcov to <pkg>/coverage/ — exactly
where sonar.javascript.lcov.reportPaths looks. 131 lines become 28.

Three things the plain root sequence still needs on a runner, kept as their own
steps so the reason stays visible:
- plugin-sdk is not a root workspace (workspaces = client, server, shared), so
  the root npm ci never reaches it and it needs its own.
- The lockfile was generated on Windows and omits @swc/core's Linux optional
  binary, so the server's SWC transform cannot load. test.yml carries the same
  step for the same reason.
- Only shared is built, not the root `build`. server and client run their suites
  from source through vitest, so building them adds a tsc pass and a full vite
  bundle that nothing here reads.

The cost is wall-clock. The first run of the parallel version took 16 minutes,
with client (11) and server (9) overlapping; sequentially that is closer to 25.
Nobody waits on this job, so the simpler file is the better trade — but it is a
trade, not a free win.
2026-08-25 16:34:08 +02:00
Maurice 616b39a9f0 ci: run the SonarQube analysis from Actions instead of by hand (#2038)
The scan has been a manual step on somebody's machine, which is why the
dashboard could sit on a stale commit without anyone noticing. This runs it on
every push to dev.

Push to dev only, and that is the point: the instance is Community Edition, so
it has no branch or pull-request analysis and the project holds exactly one
view. Analysing a feature branch would overwrite that view with the branch's
numbers until somebody scanned dev again, so the one branch that may publish is
the one everybody reads.

The four coverage jobs mirror the ones in test.yml because Sonar needs the lcov
each package emits — without it every line reports as uncovered and the
dashboard shows a collapse that did not happen. They run in parallel and hand
their reports to the scan job, which needs a full clone: Sonar reads git blame
to date each issue and to decide what counts as new code.

sonar.projectVersion stays pinned here rather than read from package.json.
package.json is still on 3.4.1 while the project has been analysed as 4.0.0 all
along, and since New Code is "previous version", taking the lower number would
silently re-base every new-code metric.

Needs two repository secrets before it can run: SONAR_TOKEN and SONAR_HOST_URL.
2026-08-25 15:51:10 +02:00
Maurice ad2119efd6 docs(wiki): correct what reading each page whole turned up (#2036)
The last pass reviewed diffs, so it never saw a new paragraph contradicting an
older one on the same page, or a long-standing claim nobody had checked.
Plugin authors were told ctx.config carries their per-user secrets and that
definePlugin takes a permissions key — neither is true, and both fail silently.
FORCE_HTTPS was said to need TRUST_PROXY, which it does not.
2026-08-25 15:22:46 +02:00
Maurice 2c2d008dc2 fix: clear every open Sonar security issue, and half the reliability ones (#2037)
* fix(security): mint idempotency keys properly, and resolve the extractor by path

Three findings out of a sweep of every open Sonar security issue. The other 49
turned out to be false positives and are documented in sonar-project.properties
rather than changed — each entry scoped to a file, never a whole rule, so a
future Math.random() on an auth path still fails.

The idempotency key is the one that can lose data. Both producers fell back to
Math.random when crypto.randomUUID was missing, and randomUUID needs a secure
context — TREK's own quickstart is http://localhost:3000 and LAN installs run on
http://192.168.x.x, so on the deployments the README documents the fallback is
what actually runs. The axios one was
`Math.random().toString(36).slice(2)`, which is not length-stable: 0.5 yields a
single character and 0 yields the empty string, which the interceptor reads as
"no key" and skips deduplication for entirely. Two writes sharing a key collapse
into one — the second gets the first one's cached response and the edit is gone
with no error anywhere. Both now come from utils/randomId, which has the
getRandomValues rung that works in insecure contexts too.

The extractor probe stored the bare binary name, so every extraction re-resolved
it through whatever PATH held at that moment. The official image sets
KITINERARY_EXTRACTOR_PATH and never reaches that branch, but a bare-metal install
without it ran an unqualified name from a PATH it does not control. It now walks
the search path itself and keeps the absolute path of the file it verified;
execFileSync also drops the /bin/sh hop the old probe needed. PATH comes through
app-config because process.env is walled off in src/.

* fix: four tests and sorts that were not doing what they claimed

Sonar reported 43 high-impact reliability issues. Reading all of them, four are
real; the rest are cosmetic or would be made worse by the suggested change.

budget.test.ts hand-built `paid: true`, but both emitters send `paid: body.paid
? 1 : 0` and the handler stores the value verbatim — the test asserted a payload
the server never sends. The line below it already used a number.

admin.controller.test.ts had a `.resolves` assertion with no await. The promise
was dropped, so that line could not fail the test.

The two to-do category lists sorted with a bare .sort(), which is byte order:
every accented name files behind the whole ASCII range, so in a German trip
"Übernachtung" lands after "Zelt". TREK ships 23 locales, so this is real data.
One side effect worth knowing: katColor picks a category's dot colour by its
index, so re-sorting can move which category shows which colour — it already
moves whenever a category is added, and the colour carries no meaning.

Deliberately not changed: the two `| 0` truncations (Math.trunc is not
equivalent — `| 0` wraps at 32 bits and these carry millisecond timestamps), the
two .map() callbacks whose second parameter is declared and used, and the sha1
in thumbnail.service (its own comment says a swap would orphan every existing
thumbnail).

* refactor: give every button a type, and prefer the Number statics

Two mechanical sweeps, no behaviour change intended in either.

A <button> with no type attribute is type="submit" per the HTML spec. Outside a
form that does nothing today, but it turns into a surprise the moment the markup
is wrapped in one, which is what Sonar's S9011 is warning about. 870 buttons get
an explicit type="button". Two things were left alone on purpose: buttons that
take a {...spread}, because the spread may carry a type and the merge order
decides which wins, and anything inside a <form> — there the answer is a
judgement call, and checking all eleven form files found every real submit button
already carrying type="submit".

The Number sweep covers only the three globals that are exactly equivalent to
their Number twin: parseInt, parseFloat and NaN. isNaN is NOT in it — the global
coerces its argument, so isNaN('abc') is true where Number.isNaN('abc') is false,
and renaming it would change what the code does. It reaches server and plugin-sdk
as well as the client; both copies of egress-policy.ts got the identical edit, so
the mirror the SDK's parity test guards stays intact.
2026-08-25 15:22:38 +02:00
Maurice 7da0080557 docs(wiki): bring the wiki in line with 4.0.0 (#2034)
* docs(wiki): correct the install and operations pages for 4.0.0

Quick Start passed docker an empty ENCRYPTION_KEY: an assignment prefix is not
expanded on the same command line, so the server silently generated its own key
instead of taking the one the page told you to set. The tag examples pinned to
the 3.x line, the documented tmpfs size did not match the shipped compose file,
and several .env values were described as reaching the container when compose
never interpolates them.

* docs(wiki): match the MCP reference to the registered surface

The scope table was missing the whole Collections group and the totals said 27
scopes across 13 groups where the code registers 29 across 14. The Cloudflare
link on the setup page pointed at an anchor on its own page instead of the
heading on Troubleshooting.

* docs(wiki): correct the plugin and admin pages

Three links to Plugin-Development carried a .md suffix, which does not resolve on
the wiki. The admin pages described tabs, permissions and storage behaviour that
had moved on.

* docs(wiki): bring the feature pages back in line with the app

Costs still documented the old panel, where categories could be added, renamed,
reordered and deleted; they are fixed now and the panel is a day-grouped ledger
with settlements. Trip sharing, journeys, maps, packing, reservations and the
account pages had all drifted in the same way.

* docs(wiki): cover the pages the first pass did not reach

The MCP catalog listed 168 of the 201 registered tools; the settlement tools and
rate_place were missing here and the Collections family from the addon page.
Several resource rows claimed a trips:* scope where the handler asks for
trips:read.

* docs(wiki): tighten the corrections a review picked apart

The audit-log resource is the row id, not the credential id; an admin password
reset does not bump password_version the way the page implied; and a few of the
new paragraphs described behaviour more confidently than the code supports.

* docs(wiki): fix what a last read-through caught

Costs shows every figure in your display currency, not the trip's, and an
expense with no payer still counts against the members it is split between.
Studio's cutoff is a 1024px viewport rather than phones alone, and the
Integrations tab is gated on the providers an admin has configured.

* docs(wiki): the last few the review caught

A passkey satisfies the MFA policy everywhere except the WebSocket handshake,
which still asks for TOTP, so a passkey-only account under that policy gets no
live updates. Mobile optimizes a day from two places, not three. The ctx.db row
carried an unescaped pipe that split the table, and the Backups table linked
with a wiki link whose pipe did the same.
2026-08-25 12:36:52 +02:00
Maurice dc74d59b11 fix(plugins): stop three ways a hostile archive or child could stall the host
All three sit in code whose whole job is to survive a plugin that misbehaves.

readTarGz trusted the tar size field, which parseInt reads with a leading minus.
At -512 the cursor advance cancels out and below it goes backwards, so the same
header is read forever while every pass appends a member — a 70-byte upload pins
the main thread and exhausts the heap, synchronously, taking HTTP, the socket
and the database with it. The entry budgets never get a say: they run on the
list readTarGz has to finish building first. Sideload is the reachable path.

The supervisor handed onMessage's promise to an EventEmitter listener, which
discards it. A rejection there is an unhandledRejection, the host has no net for
it, and Node ends the process — the supervisor killed by the plugin it contains.

dispatch() read `'_inv' in req.params` before handle()'s try/catch, so params
that are not an object threw a TypeError into exactly that discarded promise.
2026-08-25 11:41:07 +02:00
Maurice bc59289f73 fix(plugins): keep a plugin id from pointing outside its own directory
`POST /api/admin/plugins/:id/uninstall` walks its route parameter into
pluginCodeDir() and hands the result to a recursive remove. Express decodes
`%2F` after routing, so `..%2F..%2Fuploads` matches the route and reaches the
helper as `../../uploads` — path.join then lands in the uploads tree, and
`deleteData` gets the data root the same way. Verified against the express
version in the lockfile.

pluginCodeDir and pluginDataDir now reject an id that could leave its parent.
Narrower than the manifest's ID_RE on purpose: that rule wants 3-40 lowercase
characters and belongs at install time, while this one only has to make the id
unable to steer a path, so it stays permissive about length and case. Every
other filesystem sink in the backend already has its guard — storage keys,
extraction, the wiki, backup filenames. This one did not.
2026-08-25 11:41:07 +02:00
Maurice 40957e909b refactor: drop two returns that could only ever say one thing
Both functions had two exits handing back the identical value, which reads as a
decision and is none — Sonar's S3516, and the last two Blockers on the backend.

colorizeImportedTracks returned the very object it was given, coloured in place.
Saying `void` and letting the two importers return their own result is what the
function was doing all along. PLACES-SVC-005 checked the pass-through and now
checks the thing worth checking: an empty import writes no colours.

acceptInvite bailed out early on a refusal only to return the same result the
line below returns. The notification is what the early exit was really skipping,
so it is the notification that gets the condition.
2026-08-25 11:02:39 +02:00
Maurice fb76074e7c feat(mailer): say so when SMTP certificate verification is off
SMTP_SKIP_TLS_VERIFY (and the matching admin setting) turns off certificate
checking for outgoing mail. That escape hatch stays — an install behind an
internal relay with a self-signed certificate depends on it — but until now it
downgraded the connection without a word, and a transport is built per send, so
whoever flipped it had nothing to go on afterwards.

It now warns once per process, naming the host it applies to. Once, not per
mail: a line on every notification is a line nobody reads.
2026-08-25 11:02:39 +02:00
Maurice 23196aa038 refactor(demo): keep the demo password in one place
It was written out twice — once in the seeder that creates the account, once
in the config payload the login screen reads it from. The two have to match
exactly or the published demo credentials stop working, which is a poor thing
to leave to copy-paste. It moves next to DEMO_EMAIL_PRIMARY, which the same
comment block already argues for.

Also clears a Sonar S2068 "hard-coded password" on the auth service: the value
is public on purpose, but a literal next to a `demo_password` key is exactly
what the rule looks for.
2026-08-25 11:02:39 +02:00
Maurice e3c524dd02 test: give every test case an assertion of its own
SonarQube's S2699 flagged 196 test cases as assertion-less. Almost all of
them end on a bare `await screen.findByText(...)`, which does throw when the
text never shows up — the test is real, the analyser just cannot see it.
Spelling the check out keeps the behaviour identical and makes it readable.

Writing them out turned three tests red, each for a good reason:

- FE-COMP-NOTES-008 read the heading out of the loading panel, which renders
  its own copy and swaps the whole node out once the fetch settles. It now
  waits for the loaded state.
- FE-COMP-PHOTOPROVIDERS-014 matched /connected/i, which also matches the
  "Not connected" the badge starts as — the test passed whether or not the
  button did anything. Exact text now.
- FE-FILES-002 compared an empty list against the empty list it already was.
  It seeds a file first, so the 500 has something to leave alone.

JOURNEY-SVC-066 asserted nothing at all behind a "should not throw" comment;
it now says that out loud and checks no journey entry is left orphaned.
2026-08-25 11:02:39 +02:00
jubnl decc0e1a12 fix(plugins): boot enabled plugins after the rpc-registry scan, not before
PluginRuntimeService booted enabled plugins from onModuleInit, but Nest fires
same-module onModuleInit hooks in providers-array order, where the runtime
precedes PluginRpcRegistryService — so every boot-activated plugin's rpc host
was bound from a still-empty registry and its first RPC (typically db.migrate
in onLoad) came back PERMISSION_DENIED on every cold boot, until an admin
deactivated and reactivated it by hand. Move the boot reconcile to
onApplicationBootstrap, which is guaranteed to run after every module's
onModuleInit, registry scan included.

Also fix the denial message, which interpolated the plugin id where the
permission belonged ('db.migrate requires a permission "featured-guides" was
not granted'); it now names both the missing permission and the plugin.
2026-08-25 08:06:50 +02:00
jubnl 4548743874 chore(packages): add test coverage support to plugin sdk and shared package 2026-08-25 01:22:20 +02:00
jubnl 190dc482e4 fix(sonar): use resourceKey in the i18n issue-ignore entries 2026-08-25 00:49:12 +02:00
jubnl efcdbd94fb fix(client): compute "today" from the local calendar, not UTC 2026-08-25 00:44:42 +02:00
jubnl 67b71a52d1 chore(sonar): stop secret detectors false-positiving on locale files 2026-08-25 00:11:58 +02:00
jubnl bd33d0b1cf chore(sonar): scope shared specs as tests and drop a stale exclusion 2026-08-25 00:06:50 +02:00
jubnl d110b3bb6d Fix/plugin audit findings (#2031)
* fix(plugins): enforce manifest apiVersion at install and activation

* test(plugins): exercise the absent-apiVersion default

* feat(plugins): generate the core-event catalog into host-facts

* docs(plugins): explain the packing.listBags write-permission binding

* test(plugins): cover apiVersion upload refusal; tidy docblocks and generator header

* fix(plugin-sdk): validate settings options and oauth like the host

* fix(plugin-sdk): model the full manifest shape and stop dropping fields

* fix(plugin-sdk): drop non-string routeProfiles icons like the host

* docs(plugin-sdk): document host limits, oauth contract, and ignored asUserId

* feat(plugin-sdk): enforce daily budgets and meta quotas in the mock host

* feat(plugin-sdk): expose the generated core-event catalog

* feat(plugin-sdk): scaffold trek range >=3.4.0 <5.0.0 ahead of TREK 4.0

* feat(plugin-sdk): scaffold the oauth broker settings for oauth:client plugins

* chore(plugin-sdk): fix stale mirror docs and the colliding permissions export

* docs(plugin-sdk): add the rate-limit string; restore trek docblock; match host meta sizing

* feat(plugin-sdk): raise the scaffold trek floor to 4.0.0

* docs(wiki): bring the plugin pages in line with the current runtime

* docs: bring the workspace CLAUDE.md guides up to date and make them committable

* feat(plugin-sdk): roll back failed publishes, add unrelease, sync the fork, retro-sign updates

* docs(wiki): document the publish rollback, unrelease, fork sync, and retro-signing

* chore(plugin-sdk): 1.6.0
2026-08-24 23:48:36 +02:00
Maurice 4bac447ac8 fix(planner): draw only the selected day's automated transports
Closes #2019

The route toggle belongs to one day; the reservation list it filtered is the
whole trip. So turning on a day's routing let every transit reservation in the
trip draw its geometry at once, including the ones belonging to other days —
on a day that has no transport of its own, which is what the reporter saw.

Only automated transports rode it, and that is the distinction they drew: a
manually entered train or flight is type 'train'/'flight' and reaches the map
only through its own per-booking toggle. Only the transit search stores
type 'transit'.

Gate the transit branch on the day, and gate only that one — the per-booking
toggle stays trip-wide, because switching a booking's route on is a deliberate
act about that booking. The predicate is the day-order range, not the departure
day: an overnight journey carries end_day_id and has to stay on both of its
days, the same rule the sidebar timeline already applies. Day order rather than
day id, since ids stop being monotonic once days are reordered.

The day context is optional throughout, so a map without one — collections —
keeps behaving exactly as it did. Not a 4.0.0 regression: it reproduces on
3.4.1 too.
2026-08-23 23:43:14 +02:00
Maurice 6779cd2dd1 fix(studio): fit an outline map to the country the trip was in
Closes #2021

A country is one path but not one landmass. France's is five rings, three of
them Kerguelen, French Guiana and New Caledonia, so fitting a map to its
bounding box asked for 221 degrees of longitude and printed a Paris-Lyon-Nice
route as a single dot. Britain does it through South Georgia, Norway through
Svalbard, Ecuador through the Galapagos — which is why a Netherlands journey
looks perfect and a France one does not.

Satellite only escaped by accident: imagery leaves the shape list empty, so the
fit fell through to the stops. Same control, silently doing nothing.

Fit to the rings the journey is actually near, and keep the whole shape as the
fallback for a map with no route yet. The margin is the route's own size with a
two-degree floor, which is the judgement in this fix: at two degrees a
Paris-Nice route still keeps Corsica, which is the picture that trip wants,
while Kerguelen stays out.

The outlines themselves are untouched — the overseas rings are still drawn,
they just run off the frame the way a coastline does.
2026-08-23 23:43:14 +02:00
Maurice 477e44ef50 fix(journey): keep the mobile timeline at the bottom of the screen
Closes #2022

The story preview carried both `block` and `line-clamp-2`. Tailwind emits the
display plugin after line-clamp, same specificity, so `display:block` won and
`-webkit-line-clamp` — which only does anything on a -webkit-box — was inert.
The whole entry text rendered, the card grew with it, and the timeline it sits
in is bottom-anchored with no height of its own, so it climbed up the screen.
Measured at 430x932: three words put its top edge at y=744, four hundred words
at y=24, two thousand at y=-2916. That is the reporter's "at the top in one
journey, in the middle in another" — it is not the install, it is the longest
comment in the journey being opened.

The same growth explains the other half. The timeline sits above the map and is
a horizontal scroller, so once it covers the screen it takes every drag Leaflet
would have got, and only the gap between it and the dock still reaches the map.

Dropping the display utility fixes today's cards; capping the card height means
no future one can do this again. 140px is what the older journey timeline pins
its active card to, so the two look like the same product.

Also lift the map attribution over the dock, which the reporter's screenshots
show sitting on top of it. Both engines, since a phone meets either.
2026-08-23 23:43:14 +02:00
Maurice 4eee08e45c test(middleware): cover the branches the forced-HTTPS change added
The redirect grew three paths nothing exercised: a malformed APP_URL falling
back to the request host, a request the proxy already terminated TLS for, and
the health probe, which talks plain HTTP on the loopback and must not be sent
to a hostname it cannot resolve. The per-domain coverage ratchet caught the
gap - src/middleware sat at 93.44% against its 94% pin.
2026-08-23 11:59:12 +02:00
Maurice 44ddfa510e fix(client): only give a scheme to something that looks like a host
safeExternalHref prefixed https:// to anything without a scheme, which turns
a note that reads "call the hotel" into a link to a host that does not exist.
A dot and no whitespace is what separates www.hotel.com from free text.
2026-08-23 11:59:12 +02:00
Maurice 7c6a17c020 fix(client): make the new rollbacks roll back only what they wrote
The rollbacks this branch added were snapshot-shaped: capture the whole
collection before the write, restore it on any failure. That is wrong in two
ways at once. It undoes work the server already accepted when a later step is
the one that fails, and it throws away whatever a collaborator's socket event
applied while the requests were in flight.

The day-plan reorder is the clearest case: five awaits, and a failing day-note
write reverted transport positions the server had already stored. It now
records only the fields it is about to overwrite, per booking, and drops each
one as its request lands - so a note failure reaches the catch with nothing to
undo, and a booking that arrived mid-write survives.

The notification toggle had the same shape underneath: the rollback was made
surgical but the request still shipped the whole matrix built from the
pre-toggle render, so two overlapping toggles overwrote each other anyway. It
sends the one cell that changed. The test pinned the disagreement as correct;
it asserts the payload now.

Photo reorder awaits allSettled rather than all, so the UI is not snapped back
while the remaining writes are still landing - a partial run really does leave
the server holding part of the new order, and only a total refusal justifies
restoring the old one. Both the desktop editor and the mobile sheet, which was
still swallowing every rejection.

Also: the geolocation start guard is a run generation now, because a start
issued after a stop re-sets a plain flag while the first run is still parked in
the iOS permission prompt; a category rename reports once for the batch and
rethrows, so the settings dialog stops closing as if it had worked; and a 404'd
import job stops telling the user their file was invalid when it had simply
expired.

The clipboard fallback for plain-HTTP installs was pasted into three files by
this branch. It is one helper now, and the two copy buttons in the members
modal that still called navigator.clipboard unguarded use it too. Same for the
fourth local-date helper written next to the three that already import the
existing one.
2026-08-23 11:59:12 +02:00
Maurice 818021908f fix(client): correct the second pass over this branch's own client fixes
Four of the fixes already on this branch were wrong, and an adversarial read
of the diff is what turned them up.

The shared merge helper claimed to implement the server's
COALESCE(assignment_time, place_time) and did not: it pinned the embedded
times to whatever the assignment already showed, ignoring the override
column entirely. Sharing that with the WS applier traded one bug for
another - a collaborator moving a place from 08:00 to 10:00 stopped reaching
the day card for every assignment WITHOUT an override, which is most of them.
It merges the way the server projects now, and the test that was supposed to
pin this seeded a fixture the server could never produce (embedded times that
differ from the pool row with no override to explain the difference).

The per-iteration auth gate went into every loop in tripSyncManager except
the one that matters most: the mutation-queue replay, which writes server
responses straight into Dexie. After a logout that is the shared anonymous
database. The fire-and-forget tags/categories writes two lines below a gate
added in the same commit were ungated for the same reason - they resolve
later, and the check has to happen then.

safeHttpUrl is backed by placeWebsiteSchema: http(s) only, capped at 500
characters. A booking url is deliberately free-form - shared's own contract
says so in as many words, because people paste bare hosts and long provider
deep links - so guarding it with the stricter schema turned a link somebody
saved last year into dead text. safeExternalHref refuses the schemes that
execute in this origin and gives a scheme-less value https://, which is what
an address bar does with the same input.

And the memories timeout was 20s while the server's own budget for a Synology
call is 30s, doubled again when the session has expired and it re-authenticates:
a slow NAS that answered before this branch would have started reading as
disconnected. Those calls had no client timeout at all before; they have none
again, and the ones that need to give up early carry an AbortSignal.

The notes thumbnail also loses its NUL byte cache separator - it made git treat
the whole file as binary, so a security fix landed in it unreviewable - and
gains the same cache cap its chat sibling got.
2026-08-23 11:59:12 +02:00
Maurice 502a3fd4e8 fix(shared): express the URL scheme check without a control-character regex
Same behaviour, and the root lint job stops failing on no-control-regex. The
stripping is the point and has to stay: browsers drop everything at or below
U+0020 before they resolve a scheme, so a tab spliced into 'javascript:' is
still a javascript: URL to them.
2026-08-23 11:59:12 +02:00
Maurice 38f3cb3101 fix(server): the second pass over this branch's own fixes
An adversarial review of what had already landed here turned up three that
were wrong, and they are the reason for this commit.

The CSP change removed 'unsafe-eval', which is not leftover: heic-to's
libheif build initialises embind through new Function(), and without it every
iPhone .heic upload fails in the browser. 'wasm-unsafe-eval' alone was tried
once already and was not enough (93b51a0b). Put back, with the reason written
down this time and the test pinning it rather than its absence.

The idempotency in-flight guard never fired. finalize() runs when the
handler's observable completes, but Nest writes the response - and therefore
stores the row - several microtasks later, so the waiting request always woke
up before the row existed and ran the handler a second time. It releases from
the point the row is written now; finalize is the backstop for handlers that
answer another way. The test drove the two in the opposite order from Nest,
which is why it passed either way.

And the trip-scoping check treated an id that resolves to nothing like one
belonging to someone else. accommodation_id has no foreign key, so shortening
a trip's dates leaves a booking pointing at a gap - which update() has always
healed by clearing the link, and which would have become a permanent 400.

The rest: a reply to a soft-deleted chat message is refused and the create
path blanks the quote like the list path does; the ws-token buckets are keyed
by account rather than by IP, where every user behind one NAT shared a
ceiling; the weather and vacay caps read through the stream, since a chunked
response has no content-length to check; the Google host gate takes country
domains again; abandoned response bodies are cancelled instead of left to GC;
the boot failure prints its errno; and a share link that is not yours to read
now says so instead of reporting that the journey has none.

TICKETJSON gets a real repair: the in-place GLOB fix only helps installs that
have not run it yet, so an appended migration puts back the note text the
case-insensitive LIKE cut off, for rows where what it stored is not valid
JSON. A genuine receipt always parses, so it cannot be touched.

Wiki: the audit log records the IP Express resolves after TRUST_PROXY, not
the leftmost X-Forwarded-For entry a caller can write. That is the right way
round, but it means the hop count has to be accurate - said plainly in both
places now.
2026-08-23 11:59:12 +02:00
Maurice 3e0bfbbb29 fix(client): give the desktop halves the same fixes their mobile twins got
Several of the fixes in this branch landed on a mobile screen while the
desktop component with the identical bug sat in another package and was left
alone, which leaves the two disagreeing about the same handler.

So: the appearance tab flushes its debounced write on unmount, the journey
share copy guards navigator.clipboard (undefined on a plain-HTTP self-hosted
install), the admin collab toggle rolls back from a scoped snapshot instead
of a stale one, the reservation modal parses metadata through the guarded
helper rather than a bare JSON.parse that takes the route down, and the
notification toggle surfaces its failures and stops racing concurrent
toggles.

The Journey components reached the memories provider-status endpoint with a
raw fetch(), past every apiClient interceptor; they go through a small
memoriesApi now. And the server half of the OSM menu_url finding: the client
renders it through safeHttpUrl, so place-enrichment should not be handing a
javascript: value out in the first place.

The share section hides itself when the server says the link is not this
user's to manage, rather than offering a create button that 403s.
2026-08-23 11:59:12 +02:00
Maurice e72338bbbc i18n: the English left in Costs, Studio and the mobile timeline
Three surfaces still had English written straight into JSX in a codebase that
gates on key parity: the reworked expense modal, the Studio quickbar and
rotation handles, and the leg-mode menu on the mobile day plan. Keyed, and
translated into all 23 locales rather than pasted.

Also reworded the plugin geolocation permission: it told the user their
browser would ask, and it never does - TREK answers the plugin from the
page's own location permission.
2026-08-23 11:59:12 +02:00
Maurice 27be764164 test(client): the coverage that goes with the previous commits
Left out of the commits they belong to by hand, not by intent: the mobile
screen suites, the tripRepo cache-first read behind the startup-destination
fix, and the MSW handlers the stricter unhandled-request setting needs.
2026-08-23 11:59:12 +02:00
Maurice c75e958fc0 fix(client,sdk): the smaller silent failures, and a dead SDK test file
The SDK's parity suite had an unescaped apostrophe in an it() title, so the
file did not parse and vitest reported it as "no tests". Both guards in it
were dead - including the byte-identical egress-policy check the package's own
notes call the worst possible bug in that module to lose. No CI job runs the
SDK suite, so it landed quietly; the next npm publish would have failed on it,
since prepublishOnly runs the tests.

Also in the SDK: the dev preview answered trek:geolocation without checking
the geolocation:read grant, so a plugin whose manifest is missing the
permission works perfectly in dev and is dead once installed. That is the
lenient direction, which is the one that ships broken plugins.

Client side: the background-task poll never dismissed a 404'd import job and
spun forever, "Update All" collapsed its busy flag after the first plugin
settled, the lightbox had no cancellation so a slow signed URL rendered the
wrong image, the plugin geolocation watch was torn down by any unrelated
settings write, manual date entry accepted 31 February, cover previews were
never revoked, and check-all issued one request per packing item.
2026-08-23 11:59:12 +02:00
Maurice b3d8bf08f1 fix(mobile): local dates, revoked object URLs, and writes that said nothing
Four screens derived "today" from toISOString(), which is the UTC calendar
day: an expense filed at 17:00 in Los Angeles was dated tomorrow, a to-do went
overdue eight hours early in Tokyo, and a new journal entry got the wrong day
either side of midnight. All four take the viewer's own calendar day now,
through the helper the planner already had.

Three sheets built preview object URLs in render and never revoked them, so
scrolling a picker leaked one blob per pass. Photo reorder fired its PATCHes
from inside a setState updater, which StrictMode double-invokes.

And several writes failed in silence: the notification toggle, the collab
feature switch rolling back to a stale snapshot, the appearance change whose
debounced write was dropped on unmount so it reverted on the next reload. The
backup-code print page skipped the escaping its desktop twin does, the
clipboard copy threw on a non-HTTPS install, and reservation metadata was
JSON.parsed unguarded in render, which takes the whole route down.
2026-08-23 11:59:12 +02:00
Maurice 19745b7b0d fix(studio): keep "Keep mine" from throwing the document away
After a save conflict, Keep mine set the version and unblocked writes but
never re-queued the local document, and write() had already cleared the
queue before the request. So if the user picked Keep mine and then simply
closed the editor without typing anything else, saveNow() found nothing
pending and did nothing: their afternoon of edits existed only in memory,
the indicator said idle, and the server kept the other version. The exact
outcome the conflict dialog promises to prevent.

Ctrl+Z was intercepted while typing in a text field too. Studio commits text
on blur, so mid-edit the typing is not in the undo history yet: the browser's
own text undo was suppressed and a previous layout change was undone
underneath the open editor instead. Reads as random data loss.

Also: delete no longer removes locked elements, the auto-layout no longer
freezes an empty GPS path when tracks resolve after the one-shot build, the
back-cover tally cannot print "0 places", the map fetch aborts when Studio
closes, and a double Enter in the list-name field stops creating two lists.

The page-pattern gate had quietly stopped covering anything: the Desktop-shim
refactor moved every real container out of the default export the checker
reads, and its regex missed useRef<T>(null) anyway. Widened on both counts,
and the state it immediately found moved into the hooks.
2026-08-23 11:59:12 +02:00
Maurice 7959470802 fix(collab): scope the realtime handlers to the trip, and stop losing writes quietly
The notes and polls WebSocket handlers applied every event they saw without
checking which trip it belonged to, so an event for another trip the user is
a member of landed in the open one. The poll load effect had no cancellation
either, so switching trips fast let the old response win.

What's Next compared trip-local calendar dates against a UTC one while
building the time from the local clock. West of UTC that means the rest of
today disappears from the widget every evening and tomorrow's items are
labelled Today; east of UTC it happens after local midnight instead.

A note website was rendered as a raw href with no scheme check, the same
stored-javascript: shape the planner had. Category rename walked the notes in
a loop with no handling for a partial failure. And the school-holiday region
labels resolved German first for every locale.
2026-08-23 11:59:12 +02:00
Maurice 61d4fd4d64 fix(planner): scheme-check the booking URL, and stop the serial routing calls
A booking's url is rendered as a plain href to every other member of the trip
and nothing ever checked its scheme, so a collaborator could store
javascript: there. The place website next to it has been going through
safeHttpUrl for exactly this reason, with a comment saying stored values
predate the server check - this is the surface that was left. Both ends now:
safeHttpUrl at the render site, and the scheme in the shared contract so
nothing new gets stored, additively so existing rows still load.

The per-leg routing rewrite turned one multi-waypoint request per run into
one serial request per leg. A twelve-stop day is eleven round trips to a
public OSRM in sequence before a single connector appears, and toggling km/mi
does it all again. Group consecutive legs that resolve to the same mode into
one request and fire the groups concurrently, keeping leg order on the join.

Transport reordering wrote to the store and persisted with a bare catch, so
a failed PUT left the sidebar showing an order the server never got; it
reconciles now. Plus the smaller ones: the OSM menu_url rendered unchecked,
expand/collapse-all persisting to a different storage than the day list reads,
the debounced address search with no ordering guard, the accommodation save
chain with no error path, and window.__trek_map holding a destroyed map.
2026-08-23 11:59:12 +02:00
Maurice 47a4276e84 fix(client): make the offline core survive a logout and stop clobbering itself
logout() unregistered every sync trigger, and the only place that registers
them is App's mount effect - which never runs again, because logout navigates
through the router rather than reloading. Log out, log back in, go offline,
edit something: it queues and nothing ever flushes it. No online listener, no
visibilitychange, no 30s heartbeat, no WS reconnect hook. Register on auth
success instead; registerSyncTriggers is already idempotent.

The background sync loops only checked the auth gate on entry. Logout swaps
the Dexie handle to the shared anonymous database, so a trip loop or a blob
download still in flight afterwards wrote the logged-out account's itinerary
and documents into it - the database that survives logout and greets the next
person on a shared device. Check the gate per iteration.

And the remote place:updated applier replaced the whole embedded place, while
the local reducer right next to it preserves the assignment's own times. Same
event, two answers, and the remote one wipes a scheduled 09:00-10:30 off the
itinerary when someone else renames the place. One merge helper now, used by
both, so they cannot drift again. The test that should have caught it seeded
null times and asserted only the name.

Also: moving a day note deleted before it created, and restored the note only
locally when the create failed; t() interpolation was corrupted by a '$' in a
parameter value; a 429 upload was treated as permanently failed; and the
geolocation watch was started inside a state updater.
2026-08-23 11:59:12 +02:00
Maurice eadb3703bc fix(shared,tests): bound the book contract and close the gaps that hid all this
Book page setup and element geometry were unbounded numbers, so a shared
book could be saved with zero, negative or astronomical dimensions and every
consumer downstream - the canvas, the PDF export - had to cope. Day-note caps
had drifted from the limits their own doc comment states, and icon had none.

On the test side: the path filter meant a PR touching only the root lockfile
ran no jobs at all, which is exactly the change class most likely to break a
native binding; the canAccessTrip mock is hand-copied into 72 files and had
already drifted from the real query; the path-traversal test asserted only
status !== 200, so a 500 from a broken guard passed it; and one unit test
shelled out to a real kitinerary binary if the host happened to have one.
2026-08-23 11:59:12 +02:00
Maurice a7aaa29c16 fix(db): stop losing secrets and notes to the boot and migration paths
An unreadable or empty encryption-key file was treated the same as a missing
one: regenerate and overwrite. That is the one case where regenerating is
the worst possible move - every secret already at rest becomes undecryptable,
permanently. Both now refuse to start and say which file to fix.

The key-rotation script advertised re-encrypting all stored secrets while its
hardcoded column lists had drifted: the AirTrail key, the per-user LLM key
and the plugin OAuth tokens were never migrated, so they silently stopped
decrypting after a rotation. The parity test now pins the full set against
what the code actually encrypts, rather than two tables of it.

The schema_version bump ran outside the migration's transaction, so a crash
in between left the schema advanced and the version stale - the next boot
replayed a non-idempotent step and exited, turning one crash into a boot
loop. And the TICKETJSON receipt migration matched with LIKE, which SQLite
treats case-insensitively, so a note somebody had typed starting 'ticketjson:'
was cut up and its text dropped.

Also: APP_VERSION was interpolated into migration SQL instead of bound, the
capture-metadata backfill passed (photoId, userId) to a (userId, photoId)
signature and its mock keyed on the wrong argument, TRUST_PROXY=0 was
accepted and silently coerced to 1, demo-reset hardcoded the db path instead
of honouring TREK_DB_FILE, and a demo instance with no DEMO_ADMIN_PASS set
now says out loud that its admin password is the published default.
2026-08-23 11:59:12 +02:00
Maurice 8e5fdaa877 fix(server): tighten the guard, audit and token paths
Permissions cached an empty map before it had loaded, so a DB error during
the load cached permissive defaults and every later check answered from that
cache - fail-open, and it never expired. The rate limiter grew its per-IP map
forever; the GC the legacy version had was dropped in the fold. Idempotency
did a check-then-insert, so two concurrent duplicates both ran the handler.

Audit rows recorded whatever X-Forwarded-For said, which the client writes,
and the request-body debug logger redacted by exact key match - it missed
refresh tokens, api keys and PKCE verifiers. FORCE_HTTPS built its redirect
from the client's Host header. CSP asked for unsafe-eval on top of the
wasm-unsafe-eval it actually needs.

The OAuth deny path redirected to an unvalidated URI, ws-token minting had
no rate limit at all, and PKCE was skipped when code_verifier was absent
rather than refused. An MCP session resumed with a token whose scopes had
since been reduced kept the capability set it was created with, and the
session cap was checked before the async registration that increments it.

Plugin frame containment was lexical, so a symlink out of the directory was
never noticed, and instance config accepted keys the manifest never
declared.
2026-08-23 11:59:12 +02:00
Maurice 4bfb277423 fix(journey,files): close the cross-trip and role holes on the sharing surface
Files: two :id handlers took the file id from the URL and never resolved it
against :tripId. Membership of any trip was enough to read another trip's
file links (with the joined reservation titles) or to delete one. Both now
resolve the file the way every sibling handler already does.

Journey: a contributor with role 'viewer' - blocked from every other write
in the domain - could overwrite or delete the Studio book, and could read
the share token, which is an anonymous credential that outlives their
contributor status. The book writes take canEdit now and the token read
takes the owner check its own create and delete already use.

Also: the share-link MCP tool reset every flag to permissive defaults when
it was called to refresh a link, so a journey deliberately shared without
its gallery or map quietly started publishing both; setShareLink dropped
newest_first entirely; the public routes ignored the Journey addon switch;
and photo uploads committed to storage before the access check, leaking the
objects on the 403 path.

Trips: DELETE answered 403 to someone with no access at all, which turns
sequential ids into an existence oracle. A member who simply lacks
trip_delete still gets 403.
2026-08-23 11:59:12 +02:00
Maurice 515b832fa3 fix(collab): stop serving the text of deleted chat messages
A deleted message keeps its row so the client can draw the placeholder off
the flag, but listMessages still returned the original text - in the list
itself and in the reply preview joined onto whatever quoted it. Both REST
and the MCP resource read through that one method, so blanking it there
covers every surface.

Also cap the link-preview read, and delete the spooled upload when
addNoteFile refuses the request instead of leaving it on disk.
2026-08-23 11:59:12 +02:00
Maurice 97ebe23d3b fix(server): bound every outbound fetch
The rule is a timeout, a size cap and boundary validation on anything that
leaves the process, and several call sites had only the timeout. A link
preview, a maps URL resolve, a wiki fallback and a holiday lookup all read
the whole body into memory, so a caller who picks the URL picks how much
memory the server spends - a few concurrent multi-hundred-megabyte responses
is the whole heap.

Two of them also fetched more than their name suggests. resolve-url fell
through to a full page GET for any host once it found no coordinates, which
makes the server a general-purpose outbound proxy wearing its own IP. The
weather URLs took lat/lng straight from the caller into the query string.

readCapped/exceedsDeclaredLength collect the pattern transit.service.ts and
the plugin installer had each grown separately, so there is one of it now.
Callers that only scan the head of a document (og: tags, a coordinate regex)
keep the truncated bytes and find fewer fields; callers that must have the
whole thing reject it.
2026-08-23 11:59:12 +02:00
Maurice b163c1e05d fix(server): hold DELETE bodies to the same contract as every other write
The body-contract boot gate only looked at POST/PUT/PATCH, so a DELETE that
reads a body slipped past it. Two do; one was already typed, the other read
photo_id straight off an untyped Record. Give it a contract from shared like
everything else, then add DELETE to the gate so the next one cannot repeat it.
2026-08-23 11:59:12 +02:00
Maurice 3f8a7a1ea9 fix(feeds): keep subscribable calendars working under default-deny
The ICS feed routes are documented as public - the secret token in the URL
is the credential - but nothing in the code said so, and the guard this
release makes global is default-deny. Every calendar client subscribed to
/api/feed/trip/<token>.ics would have started getting 401 on upgrade, and
a calendar app that stops fetching just goes quiet.

The e2e suite asserted anonymous access the whole time. It booted
[DatabaseModule, RealtimeModule, FeedsModule], and the APP_GUARDs live in
AppModule, so it was asserting against an app that had no opinion on
anonymity. It boots the real guard stack now, and fails without the fix.

The boot ratchet could not have caught this either: it only looked at routes
carrying @Public(), and GlobalAuthGuard stands down for ANY declared chain -
so a chain of guards that refuse a caller but never resolve one answers
strangers, invisibly. Split the inventory in two and write that half down as
well. It turns up two routes nobody had listed: the passkey login ceremony,
which genuinely runs before a session exists.
2026-08-23 11:59:12 +02:00
Maurice d91eda690a fix(reservations): keep every id in a booking inside its own trip
reservation_edit on the trip in the URL says the caller may write here. It
says nothing about the ids they put in the body, and the write contracts are
open records, so day_id, place_id, assignment_id and accommodation_id all
arrived unchecked. A booking created in one trip could carry another trip's
accommodation_id, and deleting it ran the cascade against that foreign row.

The MCP tools have refused foreign ids since they were written. Put the same
check where every writer reaches it, and scope the two day_accommodations
statements to the trip so a row stored before this still cannot be followed
out of it.
2026-08-23 11:59:12 +02:00
Maurice 39c8a22352 fix(offline): replay queued writes an interrupted flush left behind
A flush marks each row 'syncing' before it sends, and only ever picks up
rows that are 'pending'. Kill the tab mid-request - the OS evicting a PWA
does exactly this - and the row stays 'syncing' forever: it keeps counting
towards the pending badge, but nothing ever retries it, fails it or shows
it as a conflict. Anything queued behind it that depends on it later fails
with 'unresolved temp id'.

Stamp the row when it goes in flight and reclaim it on the next flush. The
axios instance times out at 8s, so a minute is well past anything that
could still be running, and the mutation id doubles as the idempotency key,
so replaying a write the server already applied is answered from its cache.
2026-08-23 11:59:12 +02:00
jubnl e91ad69c83 fix(atlas): visual empty stats state 2026-08-22 18:04:38 +02:00
Maurice e9090fa33c fix(notices): confine the 4.0.0 release modal to the 4.0.x line
Without an upper bound it would greet somebody who jumped 3.x straight to
4.2 with a summary of a release they skipped, and it would run alongside
the next release's own notice.

maxVersion is exclusive, so 4.1.0 covers every 4.0.x patch. Pinned in the
registry test, together with the handover from the thank-you notice: at no
version may both be active.
2026-08-22 15:48:13 +02:00
Maurice bb7c484ca4 feat(notices): add the 4.0.0 release modal
The thank-you modal was one column of markdown for every notice. A release
needs more room than that: what shipped, and why it took what it took.

Adds a `release` block to the notice contract. A notice carrying it renders
through ReleaseNoticeModal instead of the generic body — the release on the
left, the note from the maintainer on the right, both halves driven entirely
by translation keys so the next release only edits the registry entry.

Sizing lives on one CSS variable (--rn-s), so the panel scales as a piece and
the design's proportions hold at every breakpoint.

The thank-you notice gets maxVersion 4.0.0: from there on the release modal
carries the same thank-you and the same two support links, and installs still
on 3.x keep the old one.

All 23 locales.
2026-08-22 15:48:13 +02:00
jubnl 76c8c349b1 Fold nest mcp (#2017)
* refactor(server): fold @trek/nest-mcp into server/src/nest-mcp

* docs(server): point nest-mcp references at server/src/nest-mcp

* remove tracked md file
2026-08-22 12:55:24 +02:00
jubnl 305b113d5f Feat/storage driver (#1949)
* feat(storage): storage key validation, driver contract suite, LocalDriver

* feat(storage): MirrorDriver composite backend

* feat(server): add TREK_UPLOADS_DIR env var for the uploads root

* feat(storage): settings-backed registry, StorageService facade, module wiring

* test(storage): pin src/nest/storage coverage thresholds

* feat(storage): multer upload options factory

* refactor(files): spool trip-file uploads through storage

* refactor(collab): spool note attachments through storage; normalize files/ prefix

* refactor(journey): spool journey media uploads through storage

* refactor(trips): spool trip cover upload through storage

* refactor(places): spool place-image and collection uploads through storage

* refactor(auth): spool avatar upload through storage

* fix(server): consolidate allowed_file_types onto AllowedFileTypesService

* fix(storage): default defParamCharset to utf8 in the upload factory

* test(db): re-point the trailing-migration guard at the trip_files prefix strip

* fix(storage): age-gate the boot spool sweep

* test(files): key FILE-P02 residue checks on upload size

* test(storage): raise src/nest/storage coverage pin

* refactor(platform): serve /uploads routes through storage

* refactor(files): serve trip-file downloads through storage

* refactor(memories): serve photo bytes through storage

* refactor(maps): stream place-photo bytes through storage

* refactor(share): stream shared place-photo bytes through storage

* refactor(journey): serve public journey assets through storage

* fix(share): answer place-photo cache miss with 204 like the maps proxy

* test(server): close serving-swap gaps surfaced by the full tier

* test(storage): raise src/nest/storage branches pin

* refactor(place-photos): route place-photo cache through storage

* refactor(places): reclaim place images and collection covers through storage

* refactor(trips): trip covers through storage

* refactor(auth): avatar reclaim through storage

* refactor(collab): note-file deletion through storage

* refactor(files): trash deletion through storage

* refactor(memories): trek photo cache internals through storage

* refactor(memories): thumbnail service category-addressed

* refactor(memories): immich upload reads through storage; drop uploads-root

* refactor(journey): photo deletion through storage

* fix(memories): reclaim orphaned trek photo cache objects in the ttl sweep

* feat(journey): journey-thumbs orphan sweep and delete-site derived-artifact reclaim

* test(server): close slice-4 gaps surfaced by the full tier

* test(coverage): raise slice-4 domain pins

* refactor(backup): list, existence and delete through storage

* refactor(backup): download through storage sendToResponse

* refactor(backup): create backup through per-category storage enumeration

* refactor(backup): restore and upload-restore through storage

* refactor(backup): auto-backup retention through storage

* refactor(server): boot dir-creation to driver init

* test(server): close slice-5 gaps surfaced by the coverage tier

* test(coverage): raise slice-5 backup pin

* test(server): resolve async backupFileExists mocks for the tests typecheck

* test(server): pin ranged-read stat and zero-byte roundtrip in the storage driver contract

* feat(server): TREK_S3_* env wiring — schema, s3 namespace, all-or-nothing boot rule

* feat(server): S3 driver core — aws-lite client seam, stat/delete, driver-owned deadlines

* refactor(server): type the aws-lite S3 client via s3-types reference

* feat(server): S3 driver getStream and list — ranged full-object stat, iterator pagination

* feat(server): S3 driver put — bounded-peek routing, Upload inactivity deadline

* fix(server): S3 driver Upload monitoring — Transform interposition, stream cleanup

* feat(server): env-declared s3-main storage backend; settings-declared s3 rejected

* test(server): env-gated S3 driver contract suite against MinIO

* fix(server): S3 driver zero-byte put — work around aws4 duplicate Content-Length signing defect

* fix(server): scope the zero-byte put workaround to buffered bodies; bind test MinIO to localhost

* ci(server): MinIO-backed S3 driver contract job

* ci(server): align s3-contract shim comment and MinIO bind with references

* docs: S3 storage backend configuration and off-box backup mirroring

* docs: restore README formatting, keep S3 additions only

* ci(server): make live-MinIO contract runs manual-only (AIStor licensing)

MinIO's community line is discontinued entirely; the maintained successor, AIStor, requires a license key that can't safely live in a shared CI runner. The s3-contract CI job is removed; live-provider contract runs against MinIO/AIStor are now a manual, license-gated procedure documented in docker-compose.minio-test.yml's header.

* chore(server): final-review polish — stale CI refs, dockerignore key, boundary pins, zero-byte tempfile contract case

* expose admin ui

* feat(shared): storage backend type registry, wire schemas, category relocation

* feat(server): accept settings-declared s3 backends — shared-schema options, decrypt-at-build, registry preview()

* feat(server): remove TREK_S3_*/TREK_UPLOADS_DIR — tombstone boot rule, built-in override as the relocation path

* feat(server): deriveSecurity.encryptionKeySet and storage secret plumbing (gate, encrypt, unmask, mask, redact)

* chore(server): drop storage-secrets import that only Task 8's probe helper will use

* feat(server): seed-once storage-config.json boot import — fail closed on any invalid seed

* feat(server): registry snapshot() — effective storage config with provenance

* feat(server): api/admin/storage GET/PUT — masked secrets, preview-gated saves, wired reload, audits

* feat(server): api/admin/storage/test — per-target ephemeral probes, mirror targets probed individually

* test(server): e2e coverage for api/admin/storage — guards, managed refusal, envelope, probes

* test(server): give e2e harness DBs the app_settings table the storage registry reads at boot

* test(coverage): raise pins after the storage admin-config slice

* fix(server): final-review polish — encryption-gate wording, all-field sentinel guard, README/e2e notes

* test(server): storage slice-1 review follow-ups — mirror s3-replica probe decrypt, all-encrypted seed without key, test-route 401

* feat(i18n): storage admin domain + tab label in every locale

* feat(client): adminApi storage endpoints — getStorage, updateStorage, testStorageBackend

* feat(client): storage admin draft-document helpers + relative-time util

* feat(client): schema-driven storage BackendForm — fields by kind, mask echo, encryption banner

* feat(client): admin storage panel — backends list, category map, health strip, one-PUT save

* feat(client): storage tab on the admin page

* feat(client): storage admin panel and section on the phone shell

* fix(client): block renaming a storage backend onto an existing name

* feat(server): allow mirrors on any storage category — lift the backups-only rule

* feat(i18n): replicas-on-primary mirror strings in every locale

* feat(client): mirror fold/synthesize model helpers for replicas-on-primary

* feat(client): BackendForm mirror-targets composer; raw mirror type hidden

* feat(client): desktop storage panel folds mirrors into their primaries

* feat(client): phone storage panel folds mirrors into their primaries

* fix(client): strip removed backends from remaining mirror replicas — no phantom refs

* feat(storage): retire photos as a configurable category — served-only via the ServedCategory seam

* feat(i18n): descriptive storage category names and descriptions in every locale

* feat(client): descriptive category names, descriptions and id badges in the storage panel

* docs(storage): fix stale nine-category comments after the eight-category shrink

* docs: storage configuration via the admin UI and seed file — README + compose

* docs(wiki): Admin Storage page and storage cross-links

* docs(server): storage domain blueprint entry; TREK_PLACE_PHOTO_DIR tombstone clarifier

* refactor(server): drop the storage env-var tombstones — the variables never shipped

* docs: storage config is simply new in v4 — drop the removed-variables story

* feat(shared): backfill-status and usage wire schemas on the storage admin state

* feat(server): MirrorDriver.backfill — prefix-scoped, size-compared, cancellable replica catch-up

* feat(server): one-at-a-time storage backfill jobs + the storage events bridge

* feat(server): storage usage scan — nightly cron, on-demand service, persisted with computedAt

* feat(server): replica_failure admin notifications — events bridge, hourly debounce, channel registration

* feat(i18n): replica-failure notification text in every locale

* feat(server): storage backfill and stats-refresh admin endpoints

* feat(client): adminApi backfill and stats-refresh endpoints

* feat(i18n): storage sync and usage strings in every locale

* feat(client): desktop storage panel — sync now, usage display, backfill polling

* feat(client): phone storage panel — sync now, usage display, backfill polling

* fix(client): per-category usage line on the phone storage panel

* docs: sync now, usage stats and replica-failure notifications

* test(server): admin scope now includes replica_failure, not just version_available

Commit 6db135da added replica_failure to ADMIN_SCOPED_EVENTS but left
NPREF-005b asserting the admin scope contained only version_available.
Update the expectation to match.

* test(server): cover storage backfill/stats/jobs branches to hold the coverage pin

* fix(client): guard storage admin polling against stale responses racing a save

A poll GET issued before a save's PUT resolves could land after the save
applied its fresher response, overwriting it with the pre-save world and
re-deriving the draft from that stale snapshot. useStorageAdmin now tracks
a state-application sequence number and drops any refreshState() response
whose sequence has moved on, or that resolves while a save is in flight.
Also guard the fire-and-forget refreshState() calls in startBackfill/
cancelBackfill against unhandled rejections, matching the poll's style.

* fix(server): report 404 before 409 when a backfill start names an unknown mirror

startBackfill checked the global busy flag before validating that :name is
a mirror routed by some category, so POST .../backends/garbage/backfill
answered 409 instead of 404 whenever a sync was already running. Resolve
and validate the target first (BackfillTargetError -> 404), then check
busy (BackfillBusyError -> 409).

* feat(client): two-column category grid on the storage panel

* feat(server): plugin files.getContent reads through the storage layer

* feat(server): plugin files.create writes through the storage layer

* refactor(server): drop FilesService.resolveFilePath, last direct-fs seam in the plugin surface

* fix(server): map StorageInvalidKeyError to the plugin file accessibility envelope

* feat(storage): allow credentialed backends without an explicit ENCRYPTION_KEY

Secrets are still always encrypted at rest; without an explicit key the
implicit data-directory key is used. Removes the plaintext-secret refusal
from the admin PUT and the seed import, the encryptionReady GET field, the
save-blocking banner in both admin shells, and the now-consumerless
security.encryptionKeySet env derivation. Docs now recommend (not require)
an explicit key, since the implicit key rides inside backup archives.

* test(client): pin the stale-poll release to the pre-save GET in FE-ADMIN-STOR-024

The stub deferred EVERY poll GET and rebound the release handle to the
newest one, so a tick landing after the save was released with the
pre-save payload — a response no real backend can produce, which the seq
guard rightly applied. Only the first (genuinely stale) GET is held now;
later GETs answer with the world the mock server currently holds. Also
drops the encryptionReady fixture field removed from the schema in the
previous commit.

* fix(storage): backfill puts carry content-type, stat failures report op stat, progress clamps

* feat(storage): category migration job — copy, flip, delta sweep, reclaimable tally

* feat(storage): migration start/cancel endpoints and client API

* test(client): storage admin fixtures carry the new migrations field

* fix(storage): migration POST answers 200 and validates its body via the shared schema

* fix(storage): cancel running storage jobs when a config save removes their backend

* fix(notifications): in-app replica-failure row carries the suppressed count

* feat(client): migration prompt, queue, and progress on the storage panel

* fix(client): migration queue holds a synchronous single-flight lock

* feat(client): migration prompt and progress on the mobile storage panel

* fix(server): register category-migration routes in the managed-route allow list

StorageAdminController.migrationStart/migrationCancel were added under the
controller-level @ManagedForbidden() decorator but never added to
MANAGED_ROUTE_ALLOW_LIST, so validateManagedRoutes() threw "Managed surface
changed" on every buildApp() call, failing 46 test files (any test booting
the real app).

* test(server): cover category-migration copy/sweep branches to hold the storage coverage pin

Adds MIG-005 (a target object already matching size/key is skipped in the
copy phase itself, not just the delta sweep) and MIG-006 (a copy failure
during the delta sweep is counted without undoing the flip that already
happened). src/nest/storage/**'s branch coverage was 83.91%, just under the
84% pin.

* docs(storage): category migration — move existing objects on reassign

* fix(storage): close the pre-flip cancel window and cancel re-routed migrations

A cancel landing between the last copy iteration and the category flip was
silently ignored, and a config save that re-routed the migrating category to
a third, still-defined backend never stopped the in-flight migration. Also
guards assignCategory against writing an unknown backend name, and fixes a
missing `suppressed` notification param being treated as truthy suppression.

* fix(client): migrations keep mirrors, queue waits on backfills, sweep failures surface

Reassigning a category onto a mirrored primary now posts the migration to the
mirror's wire name instead of the bare primary, so replication survives the
move. The queued-migration effect also waits out a running backfill (the
server's one-job rule spans both), and a done migration with sweep failures
now surfaces a doneFailures line in both shells, translated into every locale.

* ci(server): run the S3 contract suite against MinIO

Resurrects the s3-contract job removed in 6fbff835, adapted for AIStor:
writes the MINIO_LICENSE_KEY secret to a license file, boots the licensed
container, and runs the contract suite hermetically (no --coverage) so the
storage coverage pin keeps holding from unit tests alone. Gated to same-repo
PRs since fork PRs have no access to the license secret.

* fix(server): backup archives uploads from a remote primary

createBackup() enumerated uploads through withLocalFile, which downloads a
remote object to a temp file, hands it to the callback, then deletes it
before archiver ever reads it — archiver reads entries lazily during
finalize(), so a remote (S3) primary silently backed up zero upload bytes.

Add StorageService.getLocalPathOrNull(category, name): a narrow locality
probe that returns a real path only when one exists on disk right now (the
fs-existence check is the fail-safe half of the contract — a driver-reported
path that vanished between list() and here falls back too, not just a
path-less remote driver). The enumeration loop uses it directly when
available (zero-copy, unchanged for a local primary) and otherwise streams
the object into a per-backup staging dir under the backups spool, cleaned up
in the existing finally alongside zipSpool/dbSnap.

Also wire archive.on('warning', reject): archiver treats a missing/unreadable
entry as a warning, not an error, and skips it by default — which would
silently drop the entry from an otherwise "successful" backup.

* fix(server): client aborts stop 500ing and leaking streams

storage.service.ts sendToResponse: the local res.sendFile branch now treats
ECONNABORTED/ECONNRESET/ERR_STREAM_PREMATURE_CLOSE/syscall=write as success,
mirroring res.sendFile's own default callback (the contract
storageStaticHandler already implements caller-side). The remote branch
replaces the hand-rolled pipe+Promise with pipeline(stream, res), which
destroys the source stream on any failure — fixing the leaked S3 keep-alive
socket on a client abort — while swallowing the same client-abort error set;
a real source error still rejects so callers keep their miss contracts.

This centralizes the fix for every caller that streams through
sendToResponse: files-download, journey-public, photo-resolver,
trek-photo-cache, backup downloads, and /uploads/photos.

trek-exception.filter.ts now no-ops (destroying the socket) when headers are
already sent, so no filter path ever writes onto a response in flight.

maps.controller.ts and share.controller.ts's place-photo bytes proxies
switch their direct getStream+pipe to pipeline() with the same abort-tolerant
catch, keeping the existing 204-empty fallback for pre-header failures.

* fix(storage): mirror read fallback consults replicas on primary miss

MirrorDriver.getStream/stat now fall back to replicas on a primary miss
(StorageNotFoundError / null stat), not just an infra error — binding
decision: a read succeeds if ANY member holds the object. Only a
deterministic StorageInvalidKeyError skips fallback.

stat() gets a dedicated fallback loop instead of reusing fromReplicas:
a replica returning null means "keep looking," which the generic
catch-and-continue helper would have short-circuited on.

StorageService.sendToResponse/withLocalFile stop treating composite
driver.stat() as proof the object is at localPath; both now fs-check
the path itself and fall through to the stream branch when it's
missing, which is what actually reaches a MirrorDriver's replicas.

* feat(storage): remote serving parity — content-type, validators, range

Local backends get content type, ETag/Last-Modified, conditional GET, Range
and HEAD for free from res.sendFile; the proxying branch every remote
backend (and a local primary whose file vanished) falls through to had none
of it — every request re-streamed the whole object with no cache validator
and no way to seek.

sendStreamed() reproduces the contract from ONE pre-flight driver.stat():
a miss throws before any header is set (the caller's 404/204/next()
contract), and that same stat then answers 304s, 416s, HEADs and suffix
ranges without ever opening a body. The decisions live in a new pure module,
http-serving.ts:

- validatorsFor: the driver's ETag, else express/send's weak
  W/"<size hex>-<mtime hex>". mtimeMs 0 (S3 can report it) means no
  Last-Modified and no derived tag — serving uncached beats a 1970 header
  that reads as fresh forever.
- isNotModified: If-None-Match (weak compare, *, list) wins over
  If-Modified-Since, per RFC 9110 §13.2.2.
- decideRange: single range only, inclusive clamp, suffix computed from the
  pre-stat size, unsatisfiable → 416, If-Range strong-exact or full entity.
  A multi-range or malformed spec is answered with the full object rather
  than multipart/byteranges. If-Match/If-Unmodified-Since stay out of scope.

Range math uses the pre-stat size as the single source of truth, the ranged
getStream stat is never re-consulted, and the pipeline abort semantics are
unchanged.

S3Driver now surfaces the bucket's ETag on stat()/getStream() (on a ranged
GET it is still the full object's tag). aws-lite strips the tag's
surrounding quotes — verified live against AIStor RELEASE.2026-08-07 — and
an unquoted entity-tag is not a valid HTTP ETag, so the driver restores
exactly the quotes the SDK removed and nothing else.

* fix(server): key rotation covers storage.backends

The S3 storage backend's secretAccessKey is encrypted at rest inside the
app_settings row keyed storage.backends, but scripts/migrate-encryption.ts
didn't know about it — after a key rotation that secret stayed encrypted
under the old key and silently failed to decrypt on the next read.

The script now re-encrypts secretAccessKey for every s3 backend inside the
storage.backends JSON array, using a pinned copy of the secret-field list
(it deliberately can't import @trek/shared). The parity test pins that copy
against storageSecretFields('s3') so a new secret field can't be missed
silently.

* fix(server): restore reloads the storage registry

restoreFromZip closed and reopened the DB but never reloaded the
storage registry, so uploads rehydration ran against the STALE
pre-restore driver/category map instead of the config the restored
archive's own travel.db carries (audit #4, major).

StorageService gains reloadConfig(), a narrow passthrough to the
(unexported) registry's reload() — same shape as health(). backup.impl's
restoreFromZip now calls it right after reinitialize() and right before
the uploads wipe/rehydrateUploads/plugin staging, so rehydrated bytes
land where the restored config says. When reopen fails (reinitFailed),
reload and rehydration are skipped entirely — already reported as
"restart required", and there is no live DB handle for the registry to
read anyway.

* fix(server): category migration rewrites keys across prefix changes

Category migration copied objects to the destination backend using the
SOURCE key verbatim. A mode-A ('' prefix) source migrating onto a
prefixed backend (or vice versa) landed objects at keys the registry
could never resolve post-flip (audit #8).

Extracts StorageRegistryService.keyPrefixFor(category, backendName) from
build()'s inline conditional so startMigration can compute the
destination prefix before the flip. runMigration now maps every
destination key (destPrefix + key.slice(sourcePrefix.length)) in the
copy skip-check, the put, and the delta sweep; the reclaimable tally
stays source-keyed. Equal-prefix migrations are unaffected (identity
mapping).

* fix(plugins): await the now-async place/collection services in RPCs

PlacesService.update/remove and CollectionsService.deletePlace became
async when they started deleting storage objects, but their plugin RPC
handlers still called them synchronously: the null/false refusal check
compared against an always-truthy Promise, and places.update broadcast
a Promise instead of the resolved place. Both handlers now await the
service call; mapCollectionError is async-aware (accepts a sync-or-
async thunk) so the sync callers (create/update/savePlace/copyToTrip)
keep working through the same path. Verified the RPC host already
awaits handler results, so no change was needed there.

* fix(storage): backfill Sync now gains a deletion sweep

The Sync now backfill copied primary objects to replicas but never removed
replica objects the primary no longer has, leaving mirror read-fallback able
to serve stale data indefinitely after a failed replica delete. backfill()
now sweeps each replica post-copy: any replica key absent from the primary
key-set is re-checked against primary.stat (a race gate against the primary
regaining the key) before being deleted. Adds a deleted counter end-to-end
(driver, jobs service, shared schema, both admin shells, all 23 locales) and
a new ReplicaFailure 'list' op for a replica whose listing itself fails
during the sweep.

* feat(storage): optimistic concurrency for admin config saves

A stale admin PUT of storage settings could silently undo a category
migration's just-flipped assignment. Adds a version counter
(app_settings row storage.config_version) bumped inside the same
transaction by both writers — applyConfig and the migration flip
(assignCategory). applyConfig compares the submitted version first
and throws StorageConflictError, mapped to 409 ahead of the blanket
400. The client carries the draft's loaded version through every
local edit and maps a 409 to a distinct "settings changed underneath
you" message, refreshing state while preserving the dirty draft for
review.

* fix(storage): surface a failed config load + redact secretAccessKey

Registry load() now records the exact parse/validate failure as
lastLoadError() instead of silently falling back to last-good config or
built-in defaults; StorageAdminService exposes it as configError on the
admin state (storageAdminStateSchema extended additively), and
AdminStoragePanel renders it as a warning banner naming the error. Save
stays enabled — it's the recovery path — but an admin can no longer save
over a broken stored config without knowing.

Also adds 'secretaccesskey' to globalMiddleware's SENSITIVE_KEYS request-log
redaction list, so storage admin PUT bodies never leak the S3 secret into
debug logs.

* fix(client): admin storage panel state races

Migration prompt now recomputes computeMigrationCandidates(draft, state)
on render and again at confirm time, so a stale open-time snapshot never
drives stripCategories or the migration queue; adds a Cancel button to
the dialog. A failed startMigration POST explicitly clears the remaining
queue with a toast naming the dropped categories and refreshes state,
instead of leaving it invisibly stranded; a "Queued: X, Y" line renders
while the queue is non-empty. Testing a mirrored row now expands the
draft primary + replicas client-side and probes each individually,
instead of sending the mirror stub to the server (which resolved
targets from its own live snapshot, silently testing saved options over
unsaved draft edits). A completed/cancelled/errored backfill now renders
its terminal status alongside the Sync now button instead of hiding it,
so it can be re-run without a page reload.

* fix(storage): refuse shared replica backends so the sync sweep cannot cross categories

A mirror's "Sync now" sweep makes each replica MATCH the primary: it lists the
replica under every prefix the mirror's categories occupy and deletes the keys
the primary doesn't hold. The backups category's prefix is '' — the replica's
entire root — so making a category-serving backend (say uploads-local) a
replica of a backups mirror meant the first sync deleted every user upload.
Nothing forbade the config: validateConfig only checked that mirror targets
existed and weren't nested, and the admin picker offered every backend.

The sweep is correct, so the fix is at the config level:

- validateConfig refuses a backend that is both a mirror replica and a
  category target (directly, or as the primary of a mirror a category routes
  to), and refuses a backend replicating two mirrors whose swept prefixes
  overlap — '' overlaps everything. Disjoint prefixes stay legal.
- assignCategory (the migration flip, the other writer of the settings rows)
  refuses routing a category onto a backend that is currently a replica.
- The admin mirror-target picker (both shells) only offers backends that serve
  no category; already-selected targets stay listed so they can be unchecked.
  A category assigned to a replica still surfaces the server's 400 verbatim.

Existing shared-replica fixtures were rewritten onto dedicated replicas.

* fix(client): a 409 conflict save is recoverable without a page reload

After a 409 the admin storage panel was a dead end. refreshState leaves a
dirty draft's version alone on purpose (audit #7: the background poll must
never float a mid-edit draft past a migration flip it never carried), setDraft
re-attaches that same version to every later edit, and save() resubmits it —
so every retry 409s again and only a full page reload got the operator out,
while the banner told them to "review and save again".

The version block stays. Instead the conflict banner now offers the one action
that can clear it: discard the local edits and reload the saved settings —
a fresh GET applied through applyState, re-deriving the draft (and its
version) and clearing dirty/saveError. Bumping the version in refreshState,
or only in the 409 branch, was rejected: either resurrects audit #7, since a
full-document save would then revert a flip the draft never carried.

storage.saveConflict now points at that action instead of implying a plain
re-save works (all 23 locales). The escape lives in the shared hook, so the
phone panel renders it too.

* fix(client): deflake the queued-migration-waits-out-backfill panel test

FE-ADMIN-STOR-037 seeds a running backfill, so the panel polls GET from
first render — but the stub served the post-save world (files: off-box)
before the PUT had landed, which no real server would do. A poll
resolving while the confirm dialog was open made the confirm-time
candidate recompute see from === to, so nothing was queued and the
migration never POSTed.

The GET stub now stays faithful (pre-save world until the PUT lands,
usage included so candidates keep their object counts), and the test
deterministically forces the once-flaky interleaving: it waits for
polls to land while the dialog is open and asserts the candidate
survives them.

* fix(storage): surface the underlying cause in probe target errors

The admin storage Test probe reported only the driver wrapper's outer
message ("put failed for '…' on 'probe-s3'"), dropping the cause that
says WHY — connection refused vs missing bucket. Flatten the error's
cause chain into the probe result, appending an S3-style code when it
isn't already in the message.

* fix(storage): surface the underlying cause in replica-failure reports

Same defect as the probe fix, one layer over: MirrorDriver's
reportReplicaFailure captured only the wrapper's message, so
replica-failure notifications said "put failed" without the why.
Move the probe's cause-chain flattener into storage.types.ts as
describeError and use it in both call sites.

* fix(storage): strip ContentType from multipart Upload calls (MalformedXML)

Every multipart S3 put carrying a contentType — every backup zip and
photo over the 10MB threshold — failed with 400 MalformedXML. Upstream
@aws-lite/s3's Upload leaks the ContentType param into its final
CompleteMultipartUpload headers ({ ...xml, ...paramHeaders }), clobbering
application/xml, which flips @aws-lite/client's payload serializer to
JSON — so the server receives a JSON parts list where XML belongs.
Confirmed live against MinIO AIStor; any S3 refuses the same.

uploadMonitored now drops ContentType before s3.Upload. Multipart
objects are stored without a content-type, which TREK never reads back
(serving derives Content-Type from the key/caller, never object
metadata); single-request PutObject paths keep sending it.

Also closes the coverage gap that let this ship: the live contract
suite never crossed MULTIPART_THRESHOLD, so s3.Upload had never run
against a real server — it now round-trips a >10MB put carrying a
contentType.

---------

Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-08-22 11:33:55 +02:00
Maurice Böe 942e1932c9 fix(budget): name the category parking and file parking bookings in it
The key was 'parkings', which is not a word and does not match the 'parking'
the reservation type uses everywhere else. The shared trip page prints the
stored category verbatim, so a share link would have shown "parkings" in every
language.

The category also had no relationship to the booking type of the same name:
RESERVATION_TYPE_TO_COST_CATEGORY still sent a parking booking's auto-expense
to Transport, so the new bucket sat empty next to the costs that belong in it
while a manually typed "parking" resolved into it. Both paths agree now.
2026-08-21 14:28:53 +02:00
MikeDabrowski 999236f944 feat(budget): add "Fuel" and "Parking" expense categories
- Adds two new fixed budget categories: fuel ("Fuel") and parkings
  ("Parking"), alongside the existing set (Accommodation, Food & drink,
  Groceries, Transport, Flights, Activities, Sightseeing, Shopping,
  Fees & tickets, Health, Tips, Other).
- Icon/color metadata added for both (Fuel and ParkingCircle from
  lucide-react), plus legacy free-text aliases (gas/petrol->fuel,
  parking->parkings) for existing/imported expense rows.
- Label strings added for all 23 supported locales.
- Adds test coverage for the new categories and legacy alias mapping.
2026-08-21 14:28:53 +02:00
Maurice 875f87b885 fix(planner): open the booking a place is linked to
The place inspector summarises the booking attached to the day assignment and
then went nowhere: the strip is a bare div with no handler, no role and no
tabIndex, and PlaceInspector was never handed an editor callback in the first
place. Reached from a map marker, that strip is the only route a restaurant or
tour booking has — only transports with two positioned endpoints get map pins of
their own — so its fields and its attachments were unreachable.

The identical lookup in the day sidebar has always carried a pencil that picks
the editor by type. The inspector now takes the same onEditTransport /
onEditReservation pair and does the same: a ferry, a flight, a train opens the
transport form, everything else the reservation modal. That split matters —
ReservationModal has no transport entry in its type list, so a ferry sent
through it would have its type re-picked on save.

Each handler arrives as undefined when the user lacks the matching right, which
is what keeps the strip a read-only summary instead of turning it into a button
that does nothing. Gating inside the handler would have re-shipped the reported
symptom with a label claiming otherwise.

The phone place sheet never rendered the strip at all, so the same booking was
just as unreachable there, only invisibly. It shows it now and opens the same
two editors, closing itself first — it hangs off the place selection rather than
the sheet stack, so closeSheet() would leave the editor stacked on top of it.

Closes #2012
2026-08-21 14:19:28 +02:00
Maurice a23a81f35b fix(studio): the spread import points inward too
Missed in the sweep: "Import spread" opens a JSON file picker and wore the
outgoing arrow like the rest of them.

Refs #2006
2026-08-21 13:27:27 +02:00
Maurice 331954c1b0 feat(mobile): drag to reorder the day plan, and reach the day sheet from any day
Reordering a day on the phone meant two 15px arrows, while the same list on the
web takes a drag. It takes one here too now, via the long-press bridge tablets
already use (#1616): it watches only draggable rows inside an opted-in
container, waits out a press before claiming the gesture — so a swipe stays a
scroll — and replays real drag events, which is why the rows only needed the
receiving half. Armed in edit mode and on coarse pointers alone, so go-mode taps
and map pans are untouched and the polyfill retired in #1432 stays retired. The
arrows stay: they are the accessible path.

Both paths go through one mover, so the chronology guard and the undo entry are
the same either way.

The card header was also the only route into the day sheet, and it rendered only
when the day happened to have an accommodation or a weather reading — with the
accommodation chip as the sole tap target. A day with neither had no way in.
A day pill now leads that row unconditionally, the weather reading is a target
too, and the active day chip carries a chevron so the second tap that opens the
sheet is visible in map view as well.

Closes #1997
Closes #2004
2026-08-21 13:27:27 +02:00
Maurice 2d6c8c5157 fix(mobile): open the stay when you tap the stay
Tapping the accommodation in the day sheet opened the place it is pinned to, so
check-in, check-out and the confirmation code had no way in at all. The sheet
that edits them has taken an accId since the mobile rewrite; nothing ever passed
one.

The row is a real button now and opens that editor, with the place kept one tap
to its right for anyone who wants it. Viewers without day_edit keep the place as
their target, since the editor is no use to them. The linked hotel booking below
opens too — the day's booking list filters hotels out, so that strip was the only
place it showed and it did nothing.

Closes #2001
2026-08-21 13:27:27 +02:00
Maurice da92581a16 fix(planner): let a route choice survive the map, and keep new items on their day
Two things the mobile planner forgot.

The day sheet's route toggle only ever lived in component state, and entering
the map — or tapping a day while in it — forced it back on. Turning the route
off therefore did nothing you could see and had reset itself by the time you
looked again. The choice is now remembered per trip; the map still switches it
on by default, but only while the user has never said otherwise.

Adding a place or a booking from inside a day did not connect it to that day.
The place dropped into the unplanned pool, which on mobile is a different screen
— it looks like the save was lost — and the booking opened with no date at all.
Both entry points now carry the day they were opened from, including a
long-press on the mobile map, where the chip rail names a day at all times. The
desktop map has no such context and passes nothing, so its pool behaviour stands.

While here: the import button on the bookings and transports headers only
appeared when kitinerary was installed, though the server also accepts an import
when the LLM parser is configured (#2007).

Closes #2003
Closes #1998
2026-08-21 13:27:27 +02:00
Maurice 8b88986ca8 fix(bookings): make the reservation import findable on the desktop panel
The import is not missing from the desktop — it sits at the far right of the
bookings toolbar, next to the accent "Manual Booking", above an empty page with
nothing but the mascot on it. On a fresh trip that is exactly where nobody
looks, which is how it reads as absent.

The empty state now offers the ways in that apply: add manually, import from a
file, and AirTrail where it is connected. EmptyState grows an optional action
slot for it, so every other caller is untouched.

Closes #2007
2026-08-21 13:27:27 +02:00
Maurice 9094f05604 fix(planner): close the accommodation loop when no times are recorded
With "optimize route from accommodation" on, the drawn route still started at
the first activity on any arrival day and stopped at the last one on any
departure day. The two bookend rules ask whether you can be proven to have been
at the hotel already — but they read a missing check-in time as proof against,
and the hotel picker leaves that field blank by default. So the loop hung open
at one end on every boundary day, and at both ends for a stay booked on a single
day.

With no time recorded there is no bar to clear, so the leg is drawn. Where a
time *is* set the existing rules are untouched: a stop before check-in still
draws nothing (#1465), and a transport arrival is still not a hotel departure
(#1321).

Closes #2009
2026-08-21 13:27:27 +02:00
Maurice 5e78069223 fix(journey): saving a suggestion turns it into an entry
A trip visit shows up in the journey as a suggestion, and opening one puts you
in the entry editor. Pressing Save there only promoted it when a story, a photo
or a linked photo had been added — a title, a mood, the weather or a set of tags
all left it a suggestion, and so did saving it untouched. From the outside that
looks like the save did nothing.

An explicit Save is the user saying this is their entry now. handleSave only
ever runs from the Save button, so there is no autosave to misread as intent.

Closes #2008
2026-08-21 13:27:27 +02:00
Maurice df139a2d96 fix(ui): point import controls inward and give Google Maps its own mark
Every import button in the client wore an outgoing arrow, so the control that
pulls a booking or a place list in looked like it was about to send something
out. The places-import sheet managed to contradict itself inside one screen:
Download in the header, Upload on the row below it, Upload again on the file
step. Imports now take Download, or FileDown where the button opens a file
picker; genuine uploads and the desktop drop zones keep Upload.

The mobile places-import entry point wore an ellipsis, which promises a menu.

The "open in Google Maps" buttons drew the four-segment Google *search* G,
flattened to one colour — a glyph that reads as "run a search". They get a map
pin instead, factored into one component so the desktop and mobile copies stop
drifting apart.

Closes #2005
Closes #2006
2026-08-21 13:27:27 +02:00
Maurice ec3017f9cc fix(ui): keep dropdown panels anchored to their trigger
The three fixed-position popovers computed top/left inline while rendering,
which reads the trigger's rect exactly once. Scroll a sheet afterwards and the
trigger walks off while the panel stays nailed to the viewport; on iOS the
keyboard slides up over a panel that was placed before it existed.

useAnchoredPosition measures in a layout effect and re-measures on scroll
(capture phase, so the sheets' own scroll containers count), on resize and on
visualViewport changes. It also reports how much room is actually left, so a
list opened next to the keyboard shrinks instead of hiding underneath it.

CustomDateTimePicker clamps a fixed 268x360 calendar horizontally, which no
generic box can express, so it takes the bare re-measure signal instead.

Closes #1999
Closes #2000
2026-08-21 13:27:27 +02:00
Maurice e753603ec8 test(db): re-point the trailing-migration guard at the new last slot
The guard proves that a migration appended to the end still runs on an
install that is one version behind, by undoing what the last one did and
replaying it. Its own comment asks whoever appends the next migration to
re-point the undo, which is what this does: the trailing slot now clears the
GB rows out of place_regions rather than creating journey_books.

The Spanish row alongside it is there to catch the opposite mistake — a
migration that clears more than its own country.
2026-08-20 18:40:26 +02:00
Maurice 6a5f067663 fix(atlas): give Great Britain its counties and boroughs back
Zooming into England showed nothing below "England" itself, so boroughs
marked as visited had no polygon to light up. It stopped working in 3.1.0,
when the runtime Natural Earth fetch was replaced by the bundled geoBoundaries
assets: Natural Earth carried 232 GB regions including the London boroughs,
and geoBoundaries calls the four constituent countries ADM1. The builder asks
every country for ADM1, so GB shipped as four features.

GB now ships at ADM2 — 216 counties and unitary authorities, which is the
granularity every other country gets from its ADM1 and the granularity that
was there before. That is a deliberate one-country list: ADM2 everywhere
would multiply the bundle for no gain, since ADM1 already is the level people
think in for most countries.

Two things had to be said to data that already exists. Regions marked before
3.1.0 hold the old Natural Earth codes, and are reconciled by name within the
country, the way the 3.1.1 step already does for everywhere else; a row that
still cannot be matched is left alone rather than destroyed. And place_regions
is full of GB-ENG rows that were correct under the old bundle — nothing
re-derives a row that is already cached, so without clearing them every
English place would keep reporting "England" forever.

The bundle guard that would have caught this in 3.1.0 is now in place: four
features for a whole country is not a region layer.

Closes #1974
2026-08-20 18:40:26 +02:00
Maurice 0ce82b811b fix(atlas): count only the flights a person is actually on
4.0 lets a booking name the people it is for (#1517), and the travel figures
never read that. They scoped reservations by trip membership, so on a shared
trip everyone was credited with everyone's flights: two people meeting in New
York, one from Rome and one from Mexico City, each got both departures, and
each had "visited" the other's country.

The rule now asks whether the reservation belongs to the person asking, in
one fragment used by all three queries that derive figures from reservations
— flown distance, the passport card's countries, and Atlas's own endpoint
scan — so there is one answer to that question rather than three.

Its second half is the important one: a booking with nobody named on it
counts for everyone on the trip. Every reservation made before 4.0 looks
exactly like that, and a plain join would have taken the flown distance of
every upgrading install to zero. With no assignments anywhere the numbers are
identical to before.

Where someone HAS assigned travelers, their figures will go down after this,
because they were counting journeys they did not make. That is the point, but
it is worth a release note.

Places are untouched: they have no per-traveler concept, and this is only
about numbers derived from bookings.

Closes #1966
2026-08-20 18:40:21 +02:00
Maurice 9e13791239 fix(booking-import): geocode transport endpoints on the path clients use
An imported train, bus or rental car showed its pick-up and drop-off in the
review step and then had them gone from the saved booking: no pins, no route,
no error. reservation_endpoints.lat and lng are NOT NULL, so saveEndpoints
drops an endpoint without coordinates, and kitinerary and the LLM name
stations, stops, terminals and rental desks without geo-locating them. Only
airports arrive with coordinates, from the mapper's own table.

The lookup that was meant to fill them in existed — in confirm(), which no
client calls. Both the desktop planner and mobile go preview, review form,
ordinary save, so it never ran.

It runs in preview() now, through a helper confirm() shares, on the throttled
background lane whose own docstring names booking-import as its intended
caller. The query ladder matches the venue lookup beside it: the endpoint plus
the booking's location first, then the address, then the bare name — name-only
is exactly what fails for a desk label like "Curbside Pickup Counter 7", so it
goes last. Answers are cached per preview, since a multi-leg train repeats its
stations and the lane allows about one request a second.

An endpoint that still cannot be placed stays on the item rather than being
filtered, so the review form can show and edit it, and the preview says which
name it could not locate. That warning needed the widget to change too: it
rendered warnings only when a job found nothing at all, which is precisely
the case this is not.

Reported with the full chain, file by file.

Closes #1969
2026-08-20 18:22:29 +02:00
Maurice f574e75dbe fix(pdf): print a day in the order the plan shows it
The export ordered each day by itself, on day_plan_position — one global
number per booking, seeded automatically from the place count of whichever
day happened to render first. On any other day of a span it therefore pointed
at the wrong slot, and where it was still null the export fell back to the
order the API returned rows in, which is reservation_time ASC with nulls
first.

That is why the same trip printed correctly some of the time and swapped two
bookings the rest of it, and why the reporter's workaround worked: adding a
place and removing it again reseeded the position from the day they were
looking at.

The plan has never used that column. It picks transports per day, gives each
one the time that day shows, inserts them among the places and re-sorts the
whole day by the clock — and none of that was reachable from here, because
this file had its own copy of the ordering with subtly different rules.

So the copy is gone and the export goes through utils/dayMerge, the same
functions the plan uses. Two differences fall out of that, both of them the
plan's existing behaviour: a booking attached to a place is no longer printed
twice, and a multi-leg journey prints one row per leg at each leg's own time
rather than one row carrying every leg as a subtitle. The per-segment booking
code #1943 added is read back from the booking, since the leg object does not
carry it.

Closes #1978
2026-08-20 18:08:06 +02:00
Maurice f76ee87336 fix(nav): stop the tab pill from running under the logo and the user menu
The centred tab menu was absolutely positioned, so it had no relationship to
what sat beside it. Its width grows with every enabled addon and every page
plugin, and once it outgrew the free space in the middle of the bar it simply
ran underneath the logo on one side and the bell and user menu on the other.

The only adaptation was a hard-coded 1024px breakpoint that drops the tab
labels — a constant chosen when two or three addons was the whole story, and
one that cannot know a plugin has added anything.

So the bar is three columns in the flow now: the brand block and the action
cluster carry equal weight either side, and the pill sits between them. That
keeps it centred on the bar as before, and makes overlap something that
cannot happen rather than something a breakpoint has to predict. With
min-w-0 the pill gives way and scrolls when even it runs out of room,
instead of pushing the actions off the edge.

Measuring the pill against the free space and folding the overflow into a
menu was the other option. It buys a tidier look at a narrow width and costs
a ResizeObserver on every desktop page, a cache to keep the measurement from
oscillating against the mode it chose, and a new string in 23 locales. Not
for a fix to overlapping.

Closes #1983
2026-08-20 18:08:00 +02:00
Maurice bb0d65bb19 fix(map): read the last fitBounds call without Array.at
The client's tsconfig targets a lib without Array.prototype.at, so the two
new assertions typechecked locally against the test runner but not against
tsc. Index arithmetic instead.
2026-08-20 18:07:54 +02:00
Maurice 7aead0b713 fix(map): stop the day fit from framing everything and then drifting off it
Selecting a day compensated for the day panel twice: the fit reserves the
panel's height in its bottom padding, and then 300ms later the camera was
nudged again with panBy([0, 150]). Both came from the same change, and
together they moved the view 150px further than intended.

Wide days never showed it. A day that runs north to south does: the fit is
height-bound there, so the northernmost stop sits exactly on the top padding
line, and the nudge then pushes it off the canvas. What the user sees is the
map framing the whole day correctly and then sliding upwards, leaving the
first marker outside the view.

The padding alone is right, and it is what MapViewGL has always done with the
same numbers, so this also brings the two renderers back into agreement.

Closes #1982
2026-08-20 18:07:54 +02:00
Maurice d0cc4e2f00 fix(planner): give the day's optimize button room by dropping its label
Every button in the day's route-tools row hands the day somewhere: show the
route, open it in Google Maps, open it in CoMaps, reorder it. Two were
already icon-only. Optimize carried a label on flex: 1 with padding '6px 0',
which is no horizontal padding at all — survivable until CoMaps added a fifth
button, at which point the text sat hard against both edges of its own
button.

Icon-only now, matching the two beside it. The label stays as the accessible
name and the tooltip, so nothing about reaching the action changes, and the
row has room for whatever gets added to it next.

Closes #1981
2026-08-20 17:51:33 +02:00
Maurice a5e468829c fix(llm-parse): stop discarding an extraction Anthropic sent as a string
With the Anthropic provider, an import sometimes reported that no
reservations were found even though the document held one and the model had
extracted it. Nothing was logged, because from the code's point of view
nothing failed: the value simply was not an array, and the check that only
accepted arrays returned an empty list. What reached the user was
indistinguishable from a document with no booking in it.

The tool declares `reservations` as an array and the call forces the tool, so
it usually is one. Sometimes the model serialises the same content as a
JSON-encoded string instead — either a stringified array, or stringified and
wrapped in `{ reservations: ... }` again. Because that is a serialisation
choice made per call, the same file imported fine on one attempt and came
back empty on the next.

Normalising is now one function next to the lenient JSON parser, and the
OpenAI-compatible client uses it too — it had grown its own version of the
object-unwrap half, and two copies of this rule would drift.

It unwraps a string once and no further. A value that is still not a list
after that is not a list, and reaching deeper would start turning prose into
bookings.

Reported with the exact file and lines, and with both observed shapes.

Closes #1968
2026-08-20 17:51:29 +02:00
Maurice 9522e4ce4a fix(budget): keep an uneven split from corrupting the expense total
163.21 across two people is 81.61 and 81.60. Both are exact to the cent — the
client splits properly — but the server discarded the total it was sent and
re-derived it by adding those two as doubles, which lands on
163.20999999999998. That is the number that went into the row.

It looked like a display quirk because everything routed through Intl came
out right; only the places that print a number as it stands showed it. The
stored value was wrong all along, which is why reopening the expense showed
it back, and why the price stamped onto a linked booking carried it too.

The domain already had this rule and followed it everywhere else — the
settlement maths converts to whole cents before it adds anything. The payer
sum was the one arithmetic still done in euros, in both the create and the
update path, so it now goes through the same rule.

The client side is what makes rows that are already stored wrong stop
looking wrong: the expense form and the mobile cost sheet seed their total
from a cent-clean value, and the three panels that print a booking's price
verbatim tidy it when it parses as a number. Free text an import wrote there
passes through untouched rather than becoming NaN.

Closes #1964
2026-08-20 17:44:21 +02:00
Maurice 68bf5316ad fix(planner): stop an empty reservation event from breaking a trip for good
reservation:created and reservation:updated come in two shapes. Normally they
carry the reservation; the accommodation cascade sends them empty, as a
"something changed, go and look" ping that the event contract allows for.

The applier read the id off both. With no reservations loaded yet the
existence check never ran its callback, so the cast slipped through and the
store became [undefined]. From there every render that walked the list threw
"Cannot read properties of undefined (reading 'endpoints')" into the route
error boundary, and since the store is rebuilt from the same events, the trip
stayed unreachable in that browser. The reporter could only get out of it by
clearing localStorage.

Both appliers now decline an event with nothing on it, and the Dexie writer
does too rather than leaning on a swallowed catch. The predicate that walks
the list is total as well: it guarded the property but not the element, and
it is handed straight to Array.filter over a list it does not own.

Declining the empty ping would have left a hotel booked by one member
invisible to the others until they reloaded, so the ping is honoured the way
day:reordered already honours its own — fetch the authoritative list.

The server keeps sending what it sends. Making the cascade carry the full
reservation would mean moving the REST, MCP and RPC paths together with
their pinned tests, and would still leave the client fragile against the
next emitter that does this.

Closes #1979
2026-08-20 17:37:54 +02:00
Maurice 8f171453d3 fix(packing): keep a private item off the other members' screens over MCP
A restricted packing item, changed through an MCP tool, went to every socket
in the trip room. Ticking something off your own list through an assistant
pushed that row to every other member.

The REST and RPC surfaces have scoped these events since #858 — both go
through emitToViewers, which delivers to the owner and the recipients and
nobody else. The MCP tools called safeBroadcast, which took three arguments
and had no way to say who may see the thing that changed.

So safeBroadcast learned that fourth argument, and the packing tools pass
viewersOf(item), which is the same answer the REST route asks for. A shared
item still answers null and still goes to the room in a three-argument call,
so nothing about it changes — including what the hundred-odd broadcast mocks
in the suite record.

The client stops trusting the wire on this too. It used to `put` whatever
arrived, and the offline read hands back every cached row for the trip with
no privacy filter, so a single stray event lodged another member's item in
that browser permanently. It now drops a restricted item that is neither
mine nor shared with me — deleting rather than skipping, so a list that was
already leaked into heals itself the next time an event about that item
comes past.

Closes #1976
2026-08-20 17:30:01 +02:00
Maurice eae8480728 feat(studio): follow-ups from using Studio on a real journey (#1984)
* feat(studio): widen the document, and stop a save from losing it

The schema gains what the elements coming after it need: an icon element, a
badge that can be restyled rather than only placed, a date and a pair of
coordinates on a text binding, and the map's route, pin and road settings.

Widening it turned up two ways a book could fail to come back.

The mood badge writes its code into the document, and the schema said that
code was exactly two characters, from a first draft where it was a country.
The real codes are words, so every save was rejected and the read that
followed parsed to an empty document: seventeen spreads in, nothing out.
Widened to what the badge actually writes, and normalizeBookDocument now
drops the elements it cannot read rather than the whole book, so one bad
element costs a picture frame instead of a year of work.

The other was the body limit. The parser allowed 100kb for everything, and a
book with a routed map measures past that, so the save came back 413 and the
editor sat on "not saved" with no way forward. Book writes get their own
parser at 8mb; everything else keeps the limit it had.

* feat(studio): a date, a pair of coordinates and an icon library

Three things that had to be drawn by hand before.

A journal entry knows when it happened and where, and neither could be put
on a page: you read the date off the entry and typed it into a text box,
where it then stayed while the entry moved. Both are chips now, next to the
title and the story, and both follow their entry the way the others do.
The formatting lives in one place so the chip, the template and the resolver
cannot drift by a character — coordinates in degrees or decimals, the date
in the reader's language.

The icons are the whole Lucide set, searchable, placed as a real element
with its own colour and stroke rather than as a character in a font. The
stroke is deliberately not called strokeWidth: three separate places rescale
a field of that name by page width, and an icon whose line thins as the book
grows is not what anyone asked for.

And the badges finally have their settings. A mood or weather badge could be
placed and then only moved: the icon's colour was fixed, the icon could not
be turned off, and there was no way to keep the icon and drop the words. All
three are switches now, with the colour following the badge unless you say
otherwise.

* fix(studio): let a placed entry field follow its journal entry again

The inspector says a bound field follows the journal entry and that editing
it here breaks the link. The first half was not true: nothing ever re-read
the entry, so renaming a title in Journey left the old words on the page for
good, and the same went for every other bound field.

The resolver runs where the journey is loaded and re-reads the fields that
still point at an entry. What it will not do is as important as what it
does. A field somebody has typed over stays typed over, an entry that is
gone leaves its element alone rather than blanking it, and an entry that
answers with nothing never wipes what is already on the page.

Formatted fields — the date, the coordinates — are only re-read when the
format itself changed, which is what keeps this from quietly rewriting
books that already exist: an entry whose date was placed as a heading and
then set in capitals keeps its capitals. Checked against the real
database before it shipped, where the first version overwrote three of them.

* feat(studio): start a new book empty, with six layouts to choose from

A new journey opened onto a book that had already been laid out, which is
the wrong first move: it decides the shape of somebody's book before they
have seen it, and undoing it means deleting pages one at a time. A new book
starts empty now, and the auto layout runs when it is asked to.

The layouts themselves went from two to six, so choosing one is a real
choice: what was there covered a photo-heavy spread and a text-heavy one and
nothing in between.

* fix(studio): hyphenate the export the way the editor does, and set the bleed

Two things the press sees and the editor did not.

A paragraph broke in different places in the PDF than on screen. The print
document is written from scratch and never carried a language, so the
browser hyphenated the editor's German and the export's nothing-in-
particular. It takes the page's own language now. The wait for the sheet to
be ready also covers the images inside SVG, with a ceiling, so a map that is
slow to draw does not print half-finished.

And the bleed and the safe margin are typeable. Both were baked into every
page preset at what a photo-book vendor usually asks for, 3mm and 5mm, with
no control anywhere. That is fine until a printer asks for 5 and 10, at
which point the book cannot be made here at all: the export carries the wrong
bleed and the crop marks sit in the wrong place, which nobody finds until
the guillotine has run.

* feat(studio): rebuild the inspector

The panel on the right had grown by accretion: every element type had added
its own controls in whatever shape suited it that day, so the same idea —
a colour, a size, a toggle — looked different depending on what you had
selected, and long panels were a wall of unlabelled fields.

It is one set of controls now, used by every element: foldable sections, a
line that holds a label and its control, radio choices, checkboxes, the
app's own toggle, and a number field that holds a draft so typing "500" is
not read as "5", clamped and written back under the cursor. Duplicated
controls were merged rather than deleted; nothing an element could do before
it cannot do now.

The head carries the element's identity and its actions, so what you have
selected is readable without counting fields, and the sections have room to
breathe: what was there was correct and unreadable.

* feat(studio): a route map worth printing

The map in a book was a straight grey line between grey dots on a tile that
was chosen for a screen. At print size those tiles are read at roughly four
times the resolution they were fetched for, which is why it looked soft next
to everything around it.

The imagery comes from sources that hold up on paper: NASA's shaded relief
and EOX's cloudless Sentinel-2, both licensed for it, picked at a zoom that
matches the printer rather than the screen. Tiles are fetched at twice the
density where the source offers it, and the seam between them scales with
the tile instead of sitting at a fixed fraction of a millimetre, which under
editor zoom fell below one device pixel and showed as white threads.

The route is drawn rather than plotted. A leg that was never recorded bows,
by an amount that grows with its length and turns away from the pole, and it
dashes, because an inferred line should not claim to be a track — a recorded
track never bows. Both strokes are laid down in two passes, casing then core,
so the line stays legible over dark sea and bright desert alike. Legs can
follow roads instead, routed leg by leg with the geometry thinned before it
is stored, which is the setting a road trip wants and the one a flight
cannot have.

Stops carry their own first photograph, cut to a circle by a stencil that
exists — the first one was written into a defs block that only appears on a
map clipped to a coastline, so on an ordinary map it clipped nothing and the
photographs sat square on the page. A stop with no photograph of its own is
numbered rather than handed one from elsewhere in the journey: a marker at
one place showing a picture taken at another is a caption that lies.

Whatever the frame, the whole route is inside it. The window takes the
frame's aspect, so the fit and the imagery agree instead of one containing
what the other covers, and a map dragged narrow no longer cuts off its end.
Each linked trip can carry its own map, and a map notices a photograph added
to a stop afterwards rather than having to be placed again.

* i18n(journey): the new Studio strings, in all 23 locales

* test(journey): pin the coverage the map work brought with it

The journey domain measures higher than its floor after this round, so the
floor follows it up. Only journey moved; the generator's suggestion would
have taken memories' functions down a point, and a ratchet that can fall is
not a ratchet.

* test(studio): cover the thinning that keeps a road route saveable

The one path in roadRoute nothing exercised, and the one that matters most:
a router answers a long drive with thousands of points at a precision no
press can resolve, and all of it goes into the document. Keeping a leg whole
is what pushed a real book past the body limit.

Both ends have to survive so the line still meets its stops, and the spacing
has to stay even, because cutting a run out of the middle would straighten
exactly the bends somebody wanted to see.
2026-08-20 15:43:27 +02:00
Maurice 736599a89b feat(journey): TREK Studio — a photo book designer for Journey (#1973)
* fix(photos): serve local photos root-relative so a dotted install path works

res.sendFile(absolutePath) resolves against the rewritten req.url under the Nest
ExpressAdapter and answers a spurious 404 even though the file is there — the
same trap files-download.controller.ts:60 and plugin-frame.controller.ts:74
already work around with an explicit { root } + basename. The photo resolver
never got that treatment, so every thumbnail and original 404s on any install
whose uploads path contains a dot segment (~/.local/share/trek and friends).
The public journey asset route had it too, which took shared journeys with it.

The guards above each call already confine the path to the uploads tree, so the
root form is no weaker than what it replaces.

* feat(shared): add the book document contract for TREK Studio

Millimetres throughout, not pixels: a pixel means nothing to a print shop and
changes meaning with the device pixel ratio. CSS maps 1in onto 96px, 25.4mm and
72pt with a fixed ratio, so the editor can render the document at a scale and the
print renderer at 1:1 and land on the same page.

Paint order is the array order rather than a zIndex field that can disagree with
itself, text elements may stay bound to the journal entry they came from, and a
spread can park content a layout has no room for instead of dropping it.

* feat(journey): open TREK Studio over the journey

Studio is a child route of /journey/:id and portals itself above it, so the
journey stays mounted and shows through the panel's 16px margin — it reads as
something opened on top of the journey rather than a place you navigated to. The
URL exists for reload, the back button and shared links; the user never sees it.

Every rule in studio.css is scoped under .trek-dash on purpose: dashboard.css:95
resets button font, colour, background and padding at a specificity a bare .st-x
loses to, so an unscoped rule silently does not apply to a button.

Escape clears the selection and only leaves on a second press. In a dialog
Escape means go away; in an editor that reflex would cost you the page.

* feat(journey): build, edit and arrange the book in Studio

The auto mode turns a journey into a book once and then gets out of the way: what
it produces is an ordinary document, every element as editable as one placed by
hand. Templates work the same way — applying one pours the spread's existing
photos and text into the new arrangement and parks whatever will not fit, so
trying a text-only layout does not cost you the pictures.

Moving and resizing are hand-rolled on pointer events. The drag-and-drop
libraries in this space move items between lists and have no notion of a free
frame, a handle or a magnetic edge; HTML5 drag hands you a ghost image you cannot
control and no position until the drop. It is used for exactly the one shape it
suits — dragging a photo out of the content panel onto a frame.

Selection chrome carries two rings, dark inside light. A single line vanishes
against a photograph of its own value, and over a picture that is bright on one
side and dark on the other no measurement of the background can be right.

The sheet renders in DOM rather than on a canvas so the same component tree can
be what the print renderer runs — one renderer, nothing to drift against. Edit
affordances live in a layer above it and can never reach the page.

* i18n(journey): studio strings across all 23 locales

The empty-frame prompt breaks per language rather than copying the English line
break onto everything — forced onto Japanese or Arabic it strands a fragment on
the second line.

* refactor(journey): keep SpreadView to components only

FONT_STACKS and photoSrc move to their own module: a file that exports both
components and constants breaks Fast Refresh, and this is a file you edit
constantly while designing a page. Types come from the journey store instead of
any, and the count beside the content tabs is a badge rather than competing with
the label for the same weight.

* feat(journey): more shapes, decoration and a content search in Studio

Six shapes now instead of three — rounded rectangle, triangle and an outline-only
box join the rest, plus a quotation mark and an outlined circle under decoration.
A triangle is a clip path rather than the old CSS border trick: that hack cannot
take a fill gradient or a stroke and its size does not follow the frame the way
everything else does. A stroke sits inside the frame, or the drawn shape would be
wider than the box the editor snapped and the print would not match it.

The content panel gets a search over photos and entries. A photo matches on its
own caption and on the entry it hangs from — most photos carry no words at all,
so matching captions alone would look broken on exactly the journeys that need
it. Plain filtering over what is already in memory; a journey holds tens or
hundreds of items, not thousands.

Dates in the panel and in the book now follow the app's language through the
house formatDate rather than the browser's locale, and the entry's date and place
are two badges instead of one dot-joined string the eye has to parse.

* @
feat(journey): a shape library, picture frames and photo grids for Studio

Studio had six shapes, two frames and no grids. This is the library that
makes it usable for a book rather than for a diagram.

Shapes are SVG paths now, not clip-paths. That was forced rather than
chosen: a clipped div cannot take a stroke — the clip cuts the border in
half lengthwise — and a heart is not a box with a radius. Rectangles and
ellipses keep their old div rendering, since a box genuinely is better as
a box and every document written so far contains those two.

Two decisions in shapes.ts are worth stating.

The path *points* are scaled, not the coordinate system. Fitting a path
with preserveAspectRatio="none" scales the stroke with it, so a 0.5mm
outline on a frame twice as wide as it is tall comes out 0.7mm on the
sides and 0.35mm top and bottom. Printed next to a straight rule at
300dpi, you see it. Hence the deliberately boring path subset: absolute
M/L/C/Q/Z, numbers alternating x and y, no arcs.

And every shape fills its 0..100 box exactly, enforced by normalise() at
module load rather than asked for in a comment. The editor draws its
selection outline, its resize handles and every snap target from the
element frame, so a shape occupying 86% of its own frame cannot be
aligned against anything you can see. It costs a regularity nobody can
spot — a hexagon in a square frame still reads as a hexagon.

Frames are the same list: a picture frame is a shape with a photograph
inside it, and keeping two would mean a heart that exists as one but not
the other. Photos gain a mask and a decoration, kept independent, plus
the Polaroid, film and tape treatments that are not expressible as either.

Grids take their gutter out of the cells rather than adding it between
them, so a grid asked to fill the safe area fills it instead of
overhanging by one gutter.
@

* @
feat(journey): derive what a journey adds up to

GET /api/journeys/:id/stats — distance, days, steps, photos, places, the
furthest point, the countries in visit order, and the route itself. Read
only and derived: nothing here is stored, it is what the trips added to
the journey come to.

The route comes from the journey entries where they carry coordinates,
and falls back to the places on the linked trips where they do not —
which is the common case for a journey assembled from trips nobody has
written up yet. The two are never mixed: an entry written about a place
that is also on the trip would be two stops at one location, and the
distance would count that leg twice.

Countries read place_regions first, which Atlas already fills, and fall
through to getCountryFromCoords for the rest. That import is a plain
function from a module built without DI or Nest edges, so it costs the
journey domain no coupling to Atlas.

Distance is great-circle metres, not driving distance: there is no
routing engine here, consecutive stops are not necessarily joined by a
road, and a book that prints a road distance it never measured is worse
than one that says "as the crow flies". Metres because kilometres and
miles are a display choice.

A hotel across three nights is one place with three day assignments, so
the place query aggregates back down to one row at its earliest day —
otherwise the route would visit the hotel three times.

Two test fixes came out of writing this. journey.controller.test.ts still
asserted an absolute path from res.sendFile, which the photo fix in this
branch had already changed to the basename-plus-root pair; and
linkTripToJourney in the factories wrote to linked_at, a column that does
not exist — it had no callers, so it had never once run.
@

* @
feat(journey): travel elements — the route, the figures, the countries

A photo book of a trip is not an album: the trip carries facts, and those
facts make pages photographs cannot. Four new elements — a vector route
map, a figures panel, a country page and a set of small marks — plus the
mood, weather and pros/cons an entry already records.

Every one of them carries its own values, resolved when the element is
placed and written into the document. The print renderer is headless
Chromium loading a page; one that needs an authenticated fetch to know
what it says will print blank the day a token expires. It also makes a
book a record of a trip as it was, rather than something that quietly
rewrites last year distance when a stop is added. travelRefresh compares
what is stored against the journey and offers an update, so bringing an
element up to date is a choice.

The map is vector, from the coordinates, over silhouettes simplified
offline from the admin-0 boundaries the server already bundles for Atlas
— no licence question, nothing to fetch at render time, sharp at any
print size. It paints no background: a map is geography sitting on the
paper, not a window cut through it, so it can be laid over a photograph.

Flags are drawn rather than typed. A regional-indicator pair is the
obvious answer and works everywhere except Chrome on Windows, which has
no flag glyphs and renders the two letters instead — "DE" in a printed
book is not a flag. Thirty-three flags are exact constructions; anything
with an emblem or unequal bands is deliberately absent and falls back to
the country outline, because a wrong flag is worse than no flag.

Panel tiles render the real element small, through the page own
renderer. These differ from each other by what they say rather than by
their shape: four map styles are four maps, and no icon can tell you
which is which.
@

* @
feat(journey): manage spreads from the pages rail

Add, insert between two spreads, duplicate, reorder and delete. None of
it existed — a book was whatever the auto layout produced.

The cover and the back cover are fixed points: a book has exactly one of
each, they are single pages, and no sequence of clicks should be able to
produce a book with two covers or none. Every operation asks one
predicate whether a spread is inner, rather than each caller testing role
for itself, and a spread inserted after the back cover lands before it.

Duplicating re-ids every element it copies. Two elements sharing an id
would make selection ambiguous and every lookup that follows it wrong,
delete included.

Also: a locked element can be selected again. Locking used to switch its
hit target off entirely, which was a dead end — it could not be picked,
so the inspector never showed it, so the unlock button was unreachable
and the only way back was undo. Locked now means what it says: it cannot
be moved, and its resize handles are hidden so the editor does not offer
a drag it will refuse.
@

* @
feat(journey): let the auto layout use the travel elements

The book now opens on a summary spread — the route drawn on the left, the
figures on the right — and, for a journey that crossed a border, a country
page after it. Both are omitted rather than left empty when there is
nothing to draw: a spread holding a blank frame over the words "0 KM" is
worse than a shorter book, and every page in a printed one costs money.

Only the figures the journey actually has make it onto the page. A zero
is not a fact worth a tile, it is a hole in a composition.

Each entry gains a short accent rule above its heading — one drawn
element, and the cheapest thing here that makes a spread read as designed
rather than as text placed on a page. An entry with no photographs, which
is the one spread with a blank left page, sets its date as a numeral
there: a gap in the book becomes a pause in it.

The back cover closed with `${places} Orte` — German, hardcoded, in a
book that follows the app language everywhere else. It is a figures
element now, so it takes its labels from the same translations as the
rest of Studio.

The layout waits for the figures rather than racing them. Laying out
first would give a journey a summary spread or not depending on which
promise resolved first, and Studio already shows its loading state until
the document exists, so the wait costs nothing.
@

* @
feat(journey): the Studio wordmark replaces the book-name field

The field asked for a second name for something already named after the
journey — a box whose only real use was being left alone. The bar says
what this is instead: the lockup, inline so it takes the theme ink
through currentColor rather than needing a light-on-dark copy, and a beta
mark beside it.

Along with the strings for everything in this branch, across all 23
locales, and the two keys the name field used, removed rather than left
as orphans nobody can tell from live ones.

journey.studio.frames was "Picture frames" — too long for a tab sharing a
row with two others, and untranslated in 21 of the 23 locales, which
showed the English through. It is one word now, in every language.
@

* @
feat(journey): a free trim size, page numbers, and auto layout as a button

Three from the list the PR left open.

**Custom trim.** The five presets are what vendors actually bind, which
covers almost everyone — "almost" being the problem. A local printer with
a 24x18 press, or a book meant for a particular shelf, could not be made
here at all. Two fields under the format list, and typing in them *is*
picking custom: a book that is 210 by 240 is not square-21 with a typo,
so the preset follows rather than needing to be chosen first. Switching
to custom keeps the page it is on — it adjusts the format you have.

**Page numbers.** Set on the document and drawn by the renderer, which is
the only place that knows what number a page is. Stamping them with
pdf-lib afterwards would print numbers the editor never showed, and a book
you cannot proofread on screen is not much of an editor. They are also the
one thing on a page that is not an element: an element holding "14" would
still say 14 after its spread was moved. `startAt` exists because the
first inner spread is rarely page 1 once bound, and outer/inner is a real
distinction on a spread — getting it backwards puts both numbers in the
fold.

The controls live in the inspector empty state, which was one line of
hint text. With nothing selected, that panel is where the book own
settings belong.

**Auto layout as a button.** It ran once, on open, which made it the one
thing in Studio you could not ask for; a page that came out wrong had to
be rebuilt by hand. Now it does one spread or the whole book, both as
ordinary commits — the first thing anyone does with a button like this is
press it to see, and the second is want their page back. A single spread
keeps its id so the rail does not scroll, and refuses on anything not
built from an entry rather than replacing it with a blank.
@

* @
feat(journey): cover layouts, and five more for the spreads

The cover had none. The panel said covers are designed by hand, which was
true and unhelpful — it is the page people care most about and the one
they had no starting point for.

A cover is a different problem from a spread and gets its own set rather
than a filter over the existing one: it is a single page, the type is the
subject rather than a caption to a photograph, and a spread layout applied
to it lands half its frames past the edge. Five of them, from full bleed
to type-only, and the panel serves the right set for whichever spread is
open. The cards scale as one page there, and drop the fold line, since
there is no fold.

Five more spread layouts as well — a picture with a narrow column of
story, nine up, a mosaic around one large frame, two pictures with the
words between them, and four staggered so a row of squares stops reading
as a contact sheet.

Applying a layout also stopped mangling a single text. The old rule took
the longest text as the story and looked for a heading among the rest,
which reads a three-text spread correctly and gets a one-text cover
exactly wrong: the 30pt title *is* the longest text, so it was the story,
so a cover layout with a heading slot and no body slot had nowhere to put
it and parked the title. Size decides the heading now; length only sorts
what is left.
@

* @
feat(journey): seven self-hosted book faces, and a picker that shows them

The serif slot was Georgia. Georgia is a system font — on most desktops,
on no Linux container — so the print renderer would have substituted
something else and a book proofread in one typeface would have printed in
another. The existing Journey PDF export has exactly this bug today,
asking for Inter while only Poppins is bundled.

Every family is self-hosted through @fontsource now, all OFL, all loaded
with Studio own chunk so a face cannot arrive after the text has been
laid out and reflow the line endings someone was setting. Two sans, three
serifs from bookish to high-contrast, two display faces for a cover.
Every stack ends in a generic rather than in another named font nobody
shipped.

The three original ids are kept by name, so documents written so far
still parse — they point at bundled faces rather than at whatever the
machine happened to have.

The picker sets each family in itself. A row of chips reading
"Poppins / Lora / Bebas" in the interface font tells you their names and
nothing about the only thing anyone is choosing between.

Weights a family does not ship are disabled rather than offered: asking
for bold on a single-weight display face gets a synthesised one, which
prints as a smeared regular. Picking such a family moves the weight to
the nearest it really has.
@

* @
feat(journey): real map imagery, from the providers TREK already has

The map element drew country outlines and nothing else. That is still the
default and still the right one — nothing fetched, nothing licensed,
sharp at any print size. But an outline of Iceland cannot show which road
you took, and some books want that.

So two more sources, both reading the tile template and Mapbox token an
admin already configured for the planner and Atlas. A second place to
paste a token is a second place for it to be wrong, and a source with
nothing behind it is not offered at all — showing "Mapbox" to someone
without a token produces a grey rectangle and no explanation anywhere.

Both costs are stated rather than hidden: tiles are fetched when the page
renders, and they are raster, cut for one zoom, so printing much larger
than they were fetched goes soft.

Attribution is neither optional nor a setting. The licence requires
credit and a book is a published work — nobody can add a footnote after
it is bound. The credit is matched on the host, so an instance running its
own OSM mirror still credits OpenStreetMap, and an unrecognised source
gets none rather than an invented one. It is written into the document
when the element is placed, so the renderer cannot lose it.

The route is projected onto the tile grid when there is one. The vector
map fits itself to country outlines, and a line placed by that fit sits
next to the road rather than on it — the sort of error that only shows up
once it is printed. Outlines are dropped under imagery, where they would
be a second coastline over the one in the picture.
@

* @
fix(journey): make the Studio type-check again

The map element used tileView and projectOntoTiles without importing them,
useMapSources declared a stricter frame type than any real frame satisfies,
and the mood icon read a colour through an `in` check that types as unknown.
The page fixtures in the studio tests predate page numbers, so they build a
BookPageSetup that the contract no longer accepts — now parsed from the
schema, which cannot drift from it.
@

* @
feat(journey): the book is saved

Studio laid a book out on every open and kept it in the tab. Close the tab
and the afternoon went with it, and the auto layout was not a starting point
but the only point.

The document now lives in journey_books, one row per journey, and autosaves:
quiet for a moment after the last change, or at the latest fifteen seconds
in, so a drag across a spread is one request rather than four hundred. A
stored book wins over a fresh layout on open, and closing the editor writes
whatever is still queued.

Everyone on a journey can edit its book, so the write carries the version it
was made against and the update matches on it in a single statement. A save
that has been overtaken is refused with the current record in the body,
which turns "your work vanished" into a choice between two versions. The
server broadcasts journey:book:saved to the other editors — the version, not
the document — and a client with nothing outstanding takes the new one. A
client with unsaved edits deliberately takes nothing and conflicts on its
next save instead.
@

* @
feat(journey): export the book

The one thing Studio could not do. The button was there and disabled.

A book now goes out as sheets: every inner spread cut down the middle into
leaves in reading order, bleed carried out past the trim on all four sides,
crop marks outside that saying where to cut. The cut is a window rather than
a re-layout, so a photograph crossing the gutter still meets itself once both
halves are printed. Spreads stay whole in the other mode, which is what you
send someone to read.

The browser makes the PDF, from the editor components at 1:1. Considered and
rejected: headless Chromium on the server, and a PDF library drawing the
pages a second time. TREK is self-hosted, and neither Chromium in the image
nor a second renderer to proofread against is worth carrying when the browser
already produces exactly what the editor shows — it is also how the other
exports here work. What the browser will not write is PDF metadata and the
TrimBox entries; the marks carry that instead, as they have since long before
the format existed.

Two things had to give. `SpreadView` clipped at the trim, which is right on
screen and leaves a white hairline down every edge of a bound book, so print
mode lets the page run out and the sheet does the clipping. And the print
document is its own document: it carries the app stylesheets across so the
seven bundled faces are the ones that print, and waits for images and fonts
before it offers to save.
@

* @
fix(journey): make the Studio popovers cover what they cover

The auto-layout menu sat on the glass tokens, which carry 42-58% opacity when
the user has transparency on. That reads as depth over the workbench, where
the rails and the bar use it, and as a mess over the inspector, where this
menu opens: two layers of text through each other, neither readable.

The export dialog was worse — it named --panel and --card, which are not
tokens here, so its background resolved to nothing at all and the whole thing
was see-through.

Both are opaque now. The other glass surfaces are left alone: they float over
the workbench, and that is what the material is for.

The print preview also fits the pane. A spread sheet is over 400 mm across,
so at 1:1 the book ran off the side and had to be read by scrolling sideways.
Screen only — the print stays at full size.
@

* fix(journey): a cover keeps its own size, and a trim size can be typed

Three things the export and the page setup got wrong.

Spread mode made every sheet as wide as the widest so that one @page rule
would cover them all. That put a cover — one page — in the middle of a
spread-sized sheet with white either side, which is not a cover. Each sheet
is now sized to what is on it, and the print CSS carries a named page rule
for the single-page ones.

The trim fields clamped on every keystroke, so typing 500 meant the field saw
5, clamped it to the minimum and wrote that back under the cursor: the number
could not be typed at all, only nudged with the arrows. The field now keeps
what is being typed and tells the document about values it can use. The
arrows are unchanged, and leaving the field still clamps — someone who typed
5 and clicked away meant the smallest book.

And the autosave could loop. The save effect depended on the whole store
object, which is new on every render, so after a conflict — where the local
document is by definition not the saved one — it was a save attempt per
render: a 409, a re-render, another attempt. It depends on the callbacks now,
and a conflict holds the autosave until somebody answers it.

* feat(journey): page numbers pick their own colour

A book has dark pages and light ones, often on the same spread, so one folio
colour is wrong on half of them: the warm grey that reads on paper vanishes
into a full-bleed night photograph, and white vanishes the moment the page is
white. The way out used to be setting it per spread, which the document has
no place for.

The renderer asks what is under the number instead. It knows every element
and where it sits, so it walks them from the top down and takes the first one
that covers the spot: a shape's fill is arithmetic, a photograph gets white
with a hairline of shadow — unknowable without decoding it, and dark more
often than not once printed — and bare page takes the page colour.

A heuristic, deliberately: the alternative is sampling pixels from an image
that may not have loaded, in a renderer that also has to run for print.
Anyone it gets wrong can still pick a colour, and picking one turns it off.

* feat(journey): see who else is in the book, and where they are

Two people editing the same document without seeing each other is how you get
two people editing the same spread. So: a badge in the bar saying who has the
book open, and an arrow with a name on it for each of them.

The coordinates are millimetres on the spread, not pixels on a screen — the
two of you are at different zoom levels on different monitors, and a pixel
means nothing to the other one. They carry a spread index too, so a pointer
on page 40 is not drawn on page 2.

Ephemeral throughout. Nothing is written down, the room is the authorisation
for the pointer path, and a socket that dies takes its arrow with it rather
than leaving it on everyone else's page. Presence is keyed by socket because
two tabs are two pointers; the badge folds them back into one person, because
two tabs are not two people.

The rate limit needed room. Pointers move about ten times a second, three
times what the per-socket limit allows, and that limit is what stops a client
flooding the process — so it is now two: an outer ceiling every frame counts
against before parsing, and the original tight limit on everything a pointer
is not. Malformed JSON still counts against the tight one, which is what the
guard was for.

Throttled to ten frames a second going out and interpolated coming in: sixty
would be five times the traffic for something the eye cannot use, and drawing
ten as they land reads as a stutter rather than a hand.

* fix(journey): keep the zoom bar on screen at every zoom level

Zoomed far enough in, the bar slid off the bottom of the window — and it is
the only way back out, so the editor was stuck at 300%.

The workbench sat in a grid track sized `auto`, which grows to whatever is in
it; `overflow: auto` on the child does not stop that, because a grid item's
minimum size is its content unless told otherwise. So at 300% the box grew to
2400px inside a 660px window and took its absolutely positioned bottom edge
with it. The track is bound now, and the workbench scrolls instead of growing.

The bar also has a z-index, for the same reason it has to stay in view: it
must never be the thing something else covers.

* feat(journey): Studio replaces the old book export

The journey PDF and Studio were two ways to print the same book that disagreed
about what it looks like — and only one of them can be edited. The old export
goes: the two buttons on the desktop already sat next to the Studio one, and
the renderer they called is deleted rather than left as a second answer.

Nothing takes its place on the phone. The book is a Studio document now and
Studio is a desktop editor; a phone-sized PDF of something laid out for print
was the old export's answer to a question nobody was asking.

* feat(journey): lay the book out the way a travel book is laid out

The auto layout built every spread the same way: a picture on the left, words
on the right, and nothing else. It came out looking like a template because it
was one — and on a journey with little in it, like a stack of blank paper.

Five layouts now, chosen by what an entry has and alternating as the book goes
on. A place page for a stop nobody wrote about, with the country pale behind
the name and the coordinates under it. A story page with a small map of the
stop and the picture bleeding off three edges. A panorama across the fold. A
gallery with the hero on one page and a ruled strip on the other. And every
fourth picture spread goes full bleed with the words set over it, because a
book of loud pages has no loud pages.

The marks are what actually make these pages: a flag beside the country, a
date numeral, a chip saying which day this is, a rule along the foot with a
dot showing how far in you are. Each is trivial and together they are the
difference between a page that was designed and one that was filled in. All of
them are elements the panels can make, so what comes out can be taken apart.

The summary spread gained a heading and the furthest point drawn as two dots
and a line — that figure means nothing until it is a distance between two
names. The countries page went dark, because the printed one is: pale outlines
on near-black is the single most recognisable page in these books.

Two things the old layout got wrong are fixed on the way. Entries with nothing
on them share one page instead of taking a spread each, in the order they
happened rather than gathered at the end. And an entry whose photographs have
not arrived gets the frames they will go in, empty, rather than blank paper —
the layout is finished and the pictures are the only thing missing, which is
the right way round.

* feat(journey): build the spreads out of areas, and set the elements in ink

Two things, both about what a page is made of.

The layouts were photographs with captions. A printed travel book is built out
of areas: a page laid solid in ink with pale type on it, a band down the outer
edge, a picture that overruns the panel it sits against, a box ruled around a
block of text. Two more layouts do that now — one with a whole page in ink and
a second photograph crossing the fold onto it, one with an edge band and the
words in a ruled box, pictures at two sizes and one overlapping the hero. Six
layouts on a rotation, so the book stops reading as a template.

None of it needed a new feature. The document paints in array order, so a panel
pushed first is a panel everything else lands on; that is all layering is, and
it needed using rather than adding.

And the travel elements are ink by default rather than the app's orange. A book
is printed and a printed page is ink on paper; a colour that arrives without
being chosen turns every element into the same accent whether the book wants
one or not. Picking one is a click. The dark map is the exception — the route
goes pale there, because ink on a near-black country is a line nobody can see,
and only the default is overridden: a colour somebody chose stays theirs.

* fix(journey): three things the editor got wrong

An empty photo frame rendered on its own, ignoring frameStyle entirely — so a
Polaroid placeholder was a plain dashed rectangle until a photograph landed in
it and the chin appeared from nowhere. The two branches are folded together
now: the surround is drawn once and only what goes inside it differs, so a
frame shows what it will look like before it is filled.

Adding a page to a book with no inner pages yet put it in front of the cover,
where it could not be moved back from either — a move only ever swaps with
another inner spread, and the cover is not one. An empty book reports its last
inner spread as -1, and -1 + 1 is 0. The insert is bounded at both ends now.

And snapping only knew the outer safe lines. A spread draws that dashed rule
inset from each of its four vertical edges, so dragging inwards caught the fold
and nothing else — the line you could see just before it was not a target. All
four are, along with the centre of each page and the quarters within it: on a
spread the halfway mark is the fold, so a picture centred on its own page had
nothing to find.

* feat(journey): take the auto layout's spreads from templates drawn by hand

The layouts written as code are what a function can reason its way to: a
picture here, words there, margins that add up. What they are not is designed —
a panel bled off one edge, a shape running off a corner, a strip of pictures at
two sizes with the coordinates set beneath them. Those decisions come from
looking at a page, and the editor is where looking at a page happens.

So a spread built in Studio can now be the layout. `extract-templates.cjs`
reads a journey's book and writes its spreads out as data, with every
measurement divided by the page it was drawn on — a template built on a 210mm
square lays out on an A4 landscape book without being redrawn. `applyTemplate`
does the other half: it fills what the entry can answer for and copies
everything else as drawn.

That division is the whole contract. Text carrying a binding takes the title or
the story; empty frames take the photographs in order; the day, coordinate,
date and country marks take what the stop knows. Panels, rules and decorative
shapes are the design and are left exactly as they were put.

A template is one *for an entry* when it uses one — which has to come out of
the drawing rather than a label somebody remembers to set. A summary spread of
figures and country outlines reads perfectly well on its own, and dropping a
day's photographs into it would be nonsense.

Two templates ship with this, drawn by the person who will use them. The
built-in layouts stay as the fallback for entries no template suits.

* feat(journey): turn elements freely, and pass a spread on as a file

Rotation by the corner, the way it works everywhere else: a handle sits
diagonally outside each of the four corners of the selection, and dragging one
turns the element by the change in angle rather than to the angle under the
pointer, so it can be picked up anywhere without jumping. Held to 15 degree
steps unless shift is down. Four handles rather than one because a single
handle off the top right sits under the inspector for anything in the right
page's upper half.

And a spread can now leave the book it was made in. The zoom bar downloads the
one on screen as a file; Import, beside Add spread in the pages rail, brings one
back. Measurements travel as fractions of the page, so a spread drawn on a 210mm
square lays out correctly in an A4 landscape book, the same way the auto
layout's templates do.

What deliberately does not travel:

  - Photographs. The file carries the design, and the frames arrive empty.
  - The map source. A static map freezes the URL it was rendered from, and on a
    Mapbox instance that URL has the access token in it, so a file meant to be
    posted in a forum would carry a credential. Coming the other way, a tile
    template from a stranger is a URL every reader's browser would fetch tile by
    tile: an IP address handed to whoever wrote the file.

An imported file is treated as what it is, somebody else's JSON. It is read in
the tab that chose it and never uploaded, parsed and never evaluated, and every
element goes through the document contract, which drops unknown fields and
range-checks the rest. Colours in particular are #rrggbb or nothing. The size of
the file and the number of elements are both capped, because the damage a
hostile file can do here is not code execution, it is handing the editor more
than the server will accept and leaving a document that will not save.

Also fixes the rotation handle being drawn but not grabbable: the chrome layer
and the selection box both pass pointers through, and a handle has to opt back
in with pointer-events.

* docs(wiki): add two Studio screenshots for the addon page

The editor with a stats spread open, and the frames panel. Both are what the
Journey addon page will want once Studio ships, and both are wanted now to show
the feature off while it is still in review.

* test(realtime): cover the book room's refusals and its dead sockets

The realtime coverage ratchet caught what the book room work left behind: the
three handlers each have an early return for a message that is missing its
journey id or comes from a socket with no user, and none of them was exercised.
Every one of those is a silent failure by construction. A handler that did
something with a missing journey id would not throw, it would put a socket
somewhere it does not belong, and nobody would notice until two strangers were
looking at the same page.

The other half is sockets that are in a room but cannot be used: a connection
that dropped before its close handler ran, or one put in a room without ever
being registered. The peer list has to walk past both rather than showing a
ghost, and the broadcast has to skip them rather than throwing on a path that
runs ten times a second per editor.

Both files are back to full branch coverage, so the domain's pins hold as they
are rather than being lowered to fit.

* feat(journey): make the route map a map of the journey

Four things were wrong with it, and they compounded.

**It showed the wrong place.** The view was fitted to the country outlines as
well as the stops, so a trip that stayed inside Berlin was drawn as the whole of
Germany with two dots in the middle of it. It fits what the journey did now, and
the outlines run off the edge of the frame the way a coastline does on any real
map. Fitting to the country is still there as a deliberate choice, for the page
every travel book has, and how much air to leave around either is a setting
rather than a constant.

**It drew a line nobody travelled.** Stops joined in order is a claim about the
journey that is almost always false: it crosses mountains, water and borders the
trip went around. TREK already stores routed geometry per place, from the
planner and from imported GPX, so the map now follows the road where the trip
knows it and falls back to joining the stops where it does not. Segments stay
separate, so a trip with a flight in the middle does not grow a road across the
sea. The geometry is thinned when it is frozen into the element: a printed line
gains nothing from a point every fifteen metres, and a book carrying every GPS
fix of a three-week trip is a document nobody can save.

**The tiled and static sources drew nothing at all.** The tile grid was
computed, the route was projected onto it, and then no picture was ever
rendered — the map came out as a frame with a line across it. Three separate
faults behind that: the imagery layer did not exist, `{s}` was left unsubstituted
so every tile URL named a host that does not resolve, and the grid was cut from
the tiles the route happened to sit on rather than from what the frame shows, so
it stopped short of the edge. The frame decides the grid now, and it covers by
construction. Tiles overlap by a tenth of a millimetre, which closes the hairline
of white paper that rounding left between them.

Mapbox needed one more thing: `mapbox/standard` is a 3D GL style and the Static
Images API refuses it outright, so an instance configured for the planner's map
was configured with a style this cannot use. It falls back to the classic
equivalent, and a URL frozen into a document before that existed is repaired on
the way out rather than left broken for good.

**A map was always a rectangle.** It can be cut to the outline of the countries
now, so the picture *is* the country: no frame, no box, the page showing through
around the coastline. That is the version worth laying next to a photograph, and
the one thing a rectangle can never be. The tile grid and the vector fit are
projected from one extent so the picture and the shape it is cut to line up —
two fits meant the country was full of the wrong towns.

Also here: the credit line comes off the map itself. It is still resolved and
still stored, for the colophon, which is where a book has always credited its
sources.

* feat(journey): split the country list, and trim the map cards

The country list and the country grid were compositions somebody else made:
they decided the order, the spacing and the type size, and the only way to
change any of it was to not use them. They are gone, and what is left is the
same entry — the name over its silhouette, set the same way — as an element
placed one country at a time. Three down a page is the list, with the spacing
yours; one at 80mm is a chapter opener, which the list could never be. The name
is editable, so numbering them "1. Germany", "2. Netherlands" is typing rather
than a feature. The compact mark, outline beside the name at the size of a line
of type, stays for the places that want a mark rather than a heading.

The map section offers the four vector styles and nothing else. Tiles, Mapbox
and the cut-out are one click away in the inspector, where the rest of the map's
settings already live, and four cards that are the same map four times were
crowding out everything under them.

A map is fitted to the country by default now, which is the picture a travel
book usually wants. Fitting to the stops is still there and still matters the
moment the trip is smaller than the country it happened in.

Also: no navigation fallback from the dev service worker. In production it is
right — offline, a deep link has to be answered by the cached shell. In
development it answers a reload with the index.html it cached earlier, whose
script tags point at module URLs from before the last restart, so the browser
re-runs the previous version of the app while the editor shows the new one.
Every explanation for that is wrong until someone thinks of the service worker.

* fix(journey): drop what the trimmed map section left behind

Three bindings that only fed the cards removed in the commit before: the map
sources hook, the country codes the cut-out card needed, and the frame the
preview sized itself from. Lint had them as warnings rather than errors, so
they would have ridden along quietly.
2026-08-19 23:36:42 +02:00
Maurice 7164f4fb11 fix(photos): drop the dead initialiser eslint flagged in the exif reader 2026-08-18 22:24:29 +02:00
Maurice 503a03792e test(photos): cover the local EXIF path so the memories ratchet holds
The capture backfill landed in src/nest/memories/ with its local branch
untested, which took the domain under its coverage floor (functions 96.71 vs 97,
branches 79.67 vs 81). Raising the tests rather than lowering the gate: the path
guard against climbing out of the uploads tree, a missing path, an unreadable
file, the CreateDate fallback, empty EXIF, a throwing row lookup, and schedule().
Plus the Immich album mapping with and without coordinates.

One note left in the test file for the next person: a mock that *rejects* makes
vitest fail the run even when the code catches it, because the mock's recorded
result is a second reference to that promise with nothing attached. A bare
mockReset() is enough to trip it, so the beforeEach sets a benign default.
2026-08-18 22:24:29 +02:00
Maurice 2c333cb643 perf(journey): render the gallery in pages instead of all at once
A long trip's gallery is hundreds of tiles and every one of them was in the DOM
from the first paint, which is what made scrolling it a chore (#1614). It now
renders 60 and grows as the sentinel comes into view — the same mechanism the
provider picker already uses, rather than a second one invented for this.

Deliberately not pagination over the wire: the gallery arrives inside the one
getJourneyFull payload that the client, the MCP tool, the MCP resource and the
PDF export all read, so cutting it there would change the answer for four
consumers at once and would silently shorten the book. The reported symptom was
the scrolling, and that is client-side.

The reported 500-photo limit is not in the code — no query limit, no upload cap,
no client slice — so nothing was raised.
2026-08-18 22:24:29 +02:00
Maurice 06b7f3e690 feat(weather): answer for the hour when a past entry carries one
A journal entry made at 14:30 wants the weather of that hour. The archive path
only ever asked for daily figures, so it answered with a rainy morning and a
bright afternoon averaged into one number (#1614).

With a time in hand it now asks for the hourly series as well and reads that
hour, falling back to the daily figures when the hour has no reading — an
archive gap must not turn into no answer at all. An unparseable time is treated
as no time rather than as midnight, which would have been a confident wrong
answer. The cache key carries the hour, so the two do not shadow each other.

Nothing changes for callers that pass no time, which is every existing one.
2026-08-18 22:24:29 +02:00
Maurice 97f86dcf46 feat(journey): let quick capture take a note, not just a photo
The ask was "photos or quick notes" while travelling, and the capture sheet only
took photos (#1614) — so a moment worth catching without a picture had no way in
short of opening the full editor, which is exactly what capture mode exists to
avoid.

Two rows rather than the full sheet's three: this is a note, not the story. The
story is written later, when the entry is finished.
2026-08-18 22:24:29 +02:00
Maurice 6e4fda897f refactor(journey): stop pulling trip photos into journeys
Photos live in journeys (#1614). Linking a trip used to copy its trip_photos
rows into the journey gallery, which is how a photo one member had chosen not
to share could reach a journey at all — PR #1967 filtered that at the door, and
this removes the door.

Nothing is lost by it: the trip-photo surface lost its UI in 3.1.0, so nothing
writes to trip_photos on an install newer than that, and the client calls none
of its routes. The table and those routes stay for one more release rather than
being dropped in an append-only migration; the read paths keep working for
installs that still hold rows.
2026-08-18 22:24:29 +02:00
Maurice 56b4509b03 feat(journey): put photos on the map where they were taken
The visible half of the capture-metadata work (#1614). Photos that know their
coordinates are drawn as thumbnails on the Journey map, in the shape the report
asked for: a picture rather than an abstract pin, and a count when several
collapse into one.

Clustering is a screen-space grid rather than a library. The Journey maps have
never had clustering, and the one the planner uses hangs off react-leaflet while
this map drives Leaflet directly; bucketing by rounded pixel position is a few
lines, is deterministic, and does the job — photos of one place collapse into one
thumbnail with a count, and pulling the map apart separates them again. The layer
sits below the entry pins, because the itinerary is the point of the map and the
photos are context.

Its own effect, so photos arriving do not tear down and rebuild the map the way
the entry effect does, and redrawn on zoom and pan because the buckets are in
screen space.

The thumbnail URL is built in the page rather than the hook: it needs the share
token, which is the credential the byte proxy checks.

Also re-points the trailing-migration guard at the new last slot, which is what
its own comment asks of anyone appending one.
2026-08-18 22:24:29 +02:00
Maurice 11686be264 feat(journey): let a shared journey read newest-first
A journey shared while the trip is still running is read like a blog: the family
following along wants the latest entry at the top, not to scroll past three weeks
to find it (#1614). The owner sets it per share link, because it is a property of
how the link is meant to be read rather than of the journey, and the reader can
flip it either way.

Off by default, so an already-published link does not reorder itself under its
readers.

Only the reading order changes. Day colours and stop numbers stay chronological,
so the timeline still matches the map when the feed is upside down — which is why
the day index is looked up rather than taken from the render loop. Reuses
memories.newest/oldest, already translated in all 23 locales.
2026-08-18 22:24:29 +02:00
Maurice e809808572 fix(journey): give the public map one numbering instead of two
The marker showed the stop's position within its day; the day heading showed the
day number. Same colour, different meaning, and on mobile the heading is not
rendered at all, so the number had no key — which is exactly what the report
described: the same digits over and over in different colours (#1962).

Both now count stops across the whole journey, so every number appears once, and
the timeline carries the same number next to each geocoded entry. The day colour
still groups the day. The badge sits outside the photo-count gate on purpose: an
entry with a single photo puts its title and location in the image overlay, but
the stop number has to be readable either way.
2026-08-18 22:24:29 +02:00
Maurice 97220b72f6 feat(journey): carry capture metadata into the payload, and keep it behind share_map
The gallery rows now hand the client taken_at, lat and lng, which is what the map
and a capture-ordered gallery will read.

The coordinates needed a projection before they could ship. A photo's capture
location is a place the owner never typed and may not expect to publish, so it
follows share_map — the same switch the entry coordinates already follow. Without
that, turning the map off and leaving the gallery on would have handed out exactly
the locations the owner had just withheld. The capture time is not a location and
stays either way; a blog-style view sorts by it.
2026-08-18 22:24:29 +02:00
Maurice d2cf694882 feat(photos): read the EXIF of an uploaded photo
Closes the third source of capture metadata (#1614). Provider photos are asked
for, uploaded files carry their own answer — and until now nobody read it, so
the most common case of all, a photo taken on the trip and uploaded straight
into the journey, could never appear on the map.

exifr rather than a heavier image library: pure JS, no native build, so no
musl-pin dance in the Alpine image. Only four tags are picked, so a large file
is not fully parsed. The resolved path is checked against the uploads root
before it is opened.

One thing worth knowing before someone files a bug about it: the client
converts HEIC before upload and that conversion drops GPS, so an iPhone photo
arrives here already stripped. Nothing to read is the expected outcome more
often than not, which is why the empty case is silent.
2026-08-18 22:24:29 +02:00
Maurice 359c3afc21 feat(photos): ask the provider where and when, and stop the album path dropping it
Second half of the capture-metadata foundation (#1614).

The album paths were the asymmetry: Immich's search has always carried
exifInfo.latitude/longitude, but the album mapping quietly left them out, and
the Synology album listing never asked for the gps block at all. So a photo
picked out of an album could never be placed on a map, and the distance sort
that PR #1632 added silently had nothing to sort by. Both now carry the pair.

For the add itself the client only sends an asset id. Widening that contract
would have meant trusting the client for something the provider can be asked
for directly, and would have left the MCP path and every already-imported photo
without it — so PhotoCaptureBackfillService asks afterwards instead, detached,
skipping rows that already know both so an album import is not one provider
call per photo. A slow or unreachable provider cannot hold up the add, and a
photo without capture metadata stays the normal case.
2026-08-18 22:24:29 +02:00
Maurice 6185cf96b6 feat(photos): record when and where a photo was taken
The foundation the Journey map needs before photos can sit on it by their own
coordinates, and the gallery before it can order by when a picture was taken
rather than when it happened to be added (#1614). Three nullable columns on
trek_photos plus a partial index over the coordinate pair; a photo with neither
is the normal case, not a failure.

recordCaptureMetadata merges rather than overwrites, for the same reason
recordLocalThumbnail does: the sources disagree about how much they know. A
provider search answers with coordinates, the same provider's album listing does
not, and a local file gives them up only once its EXIF has been read. Whichever
arrives first wins, and a later, emptier answer must not blank it. Coordinates
are stored as a pair or not at all — half a pair is not a place, it is the null
island.
2026-08-18 22:24:29 +02:00
Maurice 3e4b36ac71 fix(weather): give the Open-Meteo calls a deadline
All six outbound calls ran without an AbortSignal, so a hung connection held
the caller until the socket gave up on its own. Every other outbound client in
the codebase carries a timeout; this one is the reference domain and did not.
2026-08-18 20:17:57 +02:00
Maurice 85794a5a59 fix(journey): keep the picker's date headings chronological, and name any provider
Since the provider picker started sorting by distance to the entry, the date
groups were built from the already-sorted list, so first-seen order put the day
holding the nearest photo at the top and the headings stopped reading in order.
Grouping now sorts its own output; the order within a day is untouched, so the
distance sort still does its job where it belongs.

The picker also called every non-Immich provider "Synology Photos". It now
falls back to the provider's own id, the way the gallery view already did.
2026-08-18 20:17:57 +02:00
Maurice edb487fbef feat(journey): let the desktop editor set the entry time, and reach the camera
The time was already stored, sent to the server and shown in the timeline, on
the map, in the PDF and on the public share — the desktop editor just never
rendered an input for it. So a draft carried the wall-clock time it was created
at, it showed up everywhere, and there was no way to correct it (#1614). The
column holds HH:MM:SS and a time input wants HH:MM, so both the initial value
and the dirty check now trim.

The camera gets its own hidden input rather than a capture attribute on the
existing picker: putting it there would take the photo library away on a phone,
which is the more common way in. The phone sheet already works this way.
2026-08-18 20:17:57 +02:00
Maurice 630e8efca1 fix(journey): stop unshared trip photos leaking into journeys, and gate the link
Two things found while reading the domain for discussion #1614.

A photo a trip member left unshared is denied on the trip path — memories-access
requires tp.shared = 1 — but linking that trip to a journey copied every row of
trip_photos into the gallery regardless. From there it was readable by every
journey contributor, and with an open gallery by anonymous visitors of the share
link. journey_photos.shared cannot gate that after the fact: a direct upload
writes 0 into the same column, so filtering on it would empty most galleries.
The fix is at the door instead — only shared rows are copied in.

addTripToJourney checked that the caller could reach the trip but never that
they could reach the journey, so any logged-in user could link a trip of theirs
into a stranger's journey and seed skeleton entries and photos there. The MCP
tool has always checked this; only the REST route was open. Now the service
checks it, which covers both and the internal call from createJourney.

Neither path had a test. They do now.
2026-08-18 20:17:57 +02:00
Maurice b267771772 fix(mobile): let the map show one day again
Collapsing a day card has hidden that day's places from the map since #216, and
it was the only way to reduce a busy map to one day. The phone shell does not
render DayPlanSidebar, so when it replaced the desktop page on phones that
control disappeared with it: expandedDayIds stayed null and every day's pins sat
on top of each other with nothing to do about it (#1962).

A day chip in map view now drives the same declutter the desktop chevron drives,
rather than introducing a second filter. Entering the map focuses the selected
day, tapping another chip moves the focus with it, and leaving the map clears it
so the timeline is never looking at a filtered state. Unplanned places stay
visible, exactly as the collapse path has always left them.
2026-08-18 17:40:56 +02:00
Maurice a3d1fc7808 feat(share): number the day's stops on the map and connect them in order
The public share page drew every geocoded place of the trip as an identical pin
with no sequence and no line, so an itinerary could not be read off the map at
all (#1962). It does filter to a day, but the only control for that sat below a
300px map, which is why the reporter never found it on a phone.

Markers of the selected day now carry their position in that day's order, with
the same contract the planner uses: a stop the day visits twice shows both, e.g.
"1 · 3". A dashed line connects them in order. It is a straight connector on
purpose, not a road route — drawing a real one would mean sending the itinerary
to a third-party routing service for every anonymous visitor of a shared link,
with no way for the trip's owner to opt out. A day picker now sits directly
above the map, driving the same state as the day cards below, so the two can
never disagree.

Two things nobody reported, both found while in here. Outside a day selection
every marker fell back to the placeholder indigo, because the payload nests the
category on a day's assignments but sends it flat on the trip-wide pool and only
the nested shape was read. And the map refit itself whenever the page
re-rendered for an unrelated reason — the language picker, a late FX response —
because the effect keyed on the array identity of a list the page rebuilds every
render, throwing away the viewer's pan and zoom. It keys on the coordinates now.
2026-08-18 17:40:56 +02:00
Maurice f4ee06e931 fix(share): stop the day header collapsing the title into a one-word column
The day card header on /shared/ is a single nowrap flex row, and its title block
carried `flex: 1`. That is a flex base size of 0, so the moment an accommodation
chip pushes the row past the available width the block freezes at its min-content
width while the chip and the place count stay at full content width — the ~37px
column with one word, or one CJK character, per line from #1955.

It is content-driven, not viewport-driven, which is why it also showed up on a
1149px-wide desktop window: one long hotel name, or a day carrying both a
check-out and a check-in, is enough. A breakpoint would have been the wrong fix.

The title block now shrinks proportionally and truncates, the chip can shrink and
truncates too, and the place count is pinned so it never wraps or gets pushed out
of the card. The chip also moves off its hard-coded 9px onto the 10.5px the day
sidebar uses in the app, with an icon to match — at 9px it was barely readable.
2026-08-18 17:40:56 +02:00
Maurice 28765e220b fix(places): keep the Google feature id, and give list imports the free address lookup
Two halves of #1954, both about a place arriving through a list coming out
thinner than the same place pasted as a single link.

The feature id was never dropped on purpose. googleFtidFromMapsUrl only looked
at the `?ftid=` query parameter, while a share link that resolveGoogleMapsUrl
has followed to its final hop carries the id in the `data=!…!1s0x…:0x…` path
blob — the same run the coordinates are already parsed out of two lines further
down. So every pasted maps.app.goo.gl link lost it, and the Maps button opened
a name search rather than the entry the link pointed at. The parser now reads
the blob too, but only on a /place/ URL: a /maps/dir/ route carries one !1s per
waypoint and the first is the origin, which would attach the wrong place.

List imports never had a reverse lookup at all. The only pass behind them is
the Google enrichment, which returns immediately without a Maps key, so an
instance without one imported a list and left every address empty forever.
They now get the same free Nominatim lookup the single-link path has always
had: detached after the import answers, on the throttled background lane so it
never queues in front of someone's search, through COALESCE so it can only fill
an empty column, and each row pushed over the websocket so the sidebar fills in
without a reload. Capped at 250 places — past that it stops being a backfill
and starts being bulk geocoding.

Not included: an osm_id for imported places. Single links do not produce one
either, and one derived from a zoom-18 reverse lookup would name the building
or the street rather than the POI.
2026-08-18 17:40:56 +02:00
Maurice c9278c02ce chore(deps): lift the four packages the Scout gate flags as high
The security scan on the release PR has been red on six HIGH CVEs, all of
them transitive production dependencies of the server workspace, which is
what ends up in the runtime image via `npm ci --workspace=server --omit=dev`:

  brace-expansion  2.1.1 -> 2.1.4   CVE-2026-13149 / -69152 / -14257, ReDoS
                                     reached through archiver -> glob -> minimatch
  ip-address      10.2.0 -> 10.5.0  CVE-2026-69192, improper input validation
                                     reached through the MCP SDK -> express-rate-limit
  fast-uri         3.1.4 -> 3.1.5   CVE-2026-18446, reached through the MCP SDK -> ajv
  js-yaml          4.3.0 -> gone    GHSA-5p4m-2wfm-xmqj

Every one of them resolves inside a range the manifests already declare, so
this is a lockfile change and nothing else: no new dependency, no override,
no package.json edit.

js-yaml was the awkward one. @nestjs/swagger pins it exactly rather than by
range, and 11.4.5 pins the affected 4.3.0. 11.4.7 pins 5.3.0 instead, and
server already asks for ^11.4.5, so the affected copy leaves the production
tree without forcing anything. The root js-yaml 4.3.0 stays for eslint, which
never reaches the image.

Walking the production graph over the lockfile gives the same 482 packages
before and after, with only those four differing. Server suite is green
(409 files, 7805 tests), as are typecheck, typecheck:tests and client lint;
/api/docs-yaml still renders through swagger's new js-yaml.

Still open and deliberately not in here, both MEDIUM and below the gate's
critical/high filter: dompurify (via isomorphic-dompurify, and #1694 reverted
a bump of it once already) and hono (via the MCP SDK).
2026-08-18 16:11:03 +02:00
34ezz623-create 3011bf50b4 refactor(i18n): improve Turkish translations for vacay module
Replaces the machine-translated strings in the Vacay domain. The visible ones:
'Doygunluk' (saturation) and 'Güneş' (the star) were the Saturday and Sunday
column headers, 'Sol' (left, as in direction) stood for the remaining days,
'Efsane' (myth) for the legend, and 'Sigortala' (insure) read "fuse" as an
electrical fuse. Buttons move from the infinitive to the imperative the rest
of the locale already uses.

Rebased onto dev and resolved as a union: the branch carried the file as it
stood on 25 July, which would have dropped the 35 keys dev has gained since
from #1074, #1631, #1633 and #1637. Those keep their existing translations;
only the 47 strings this change actually rewords differ.

vacay.selectRegion keeps '(zorunlu)'. en says '(required)' and the Add button
stays disabled until a region is picked, so '(isteğe bağlı)' would have sent
Turkish users looking for a button that never enables.
2026-08-18 13:58:58 +02:00
Maurice 408e39b9d3 docs(wiki): list the two transport-mode tools in the MCP catalog
Every other tool on those two controllers has a row; these were the only
ones missing.
2026-08-18 13:56:44 +02:00
Maurice 02f7320c90 fix(mcp): name the route profile keys the router actually accepts
Both tool descriptions offered "transit" as an example. RouteCalculator only
knows driving, walking and cycling plus the plugin:<id>/<profile> form, and
maps anything else to driving when it draws the route, so an agent that set
"transit" got a success and a car route with a car icon. Name the real keys
and say plainly what happens to anything else.

The incoming-leg test used "transit" too. It now uses walking, and the
outgoing test seeds the incoming column first, so its "must not touch
incoming" assertion proves something the factory did not already give it.
2026-08-18 13:56:44 +02:00
Ida Rogalska e375f9e002 feat(mcp): add set_day_default_transport_mode tool
Wrap DaysService.setDefaultTransportMode behind an MCP tool, mirroring the
days/:id/transport REST route. Adapter only; per-segment leg modes still
override the day default.
2026-08-18 13:56:44 +02:00
Ida Rogalska 03f3c5ae36 feat(mcp): add set_leg_transport_mode tool
Wrap the existing per-leg transport setters (outgoing + incoming) behind an
MCP tool so leg modes no longer need a raw DB write. Adapter only, no new
business logic; mirrors the assignments/:id/transport REST route.
2026-08-18 13:56:44 +02:00
michael-bohr 55aef8d55c fix(tests): generate trip days in UTC so a DST shift can't drop or repeat one
The test trip factory parsed date-only ISO strings as UTC midnight but stepped
the cursor with setDate(), which advances local wall-clock time. Across a DST
transition that step is 23h or 25h, so the instant drifts off midnight and
toISOString() reports the wrong day — a day is dropped in autumn and repeated
in spring.

TRIP-SVC-015 already builds a trip over 2025-11-01..2025-11-03, which straddles
the US fall-back, and fails on any machine in a DST-observing zone:

  AssertionError: expected [...] to have a length of 5 but got 4

CI runs UTC, where the loop happens to be correct, so this is invisible there
and only bites contributors running the suite locally.

Full server suite: 396 files, 7550 tests, green.
2026-08-18 13:37:06 +02:00
Maurice 412de9df4d docs(readme): serve the tour as animated webp instead of a 91 MB gif
Same 1110 frames, same 1100x618, same behaviour in the page: it is still an img,
so it starts on its own, loops forever and has no player chrome. GitHub strips
autoplay and loop off a video tag, so a video was never an option here anyway.

91.0 MB to 18.7 MB, and it looks better rather than worse. The gif was capped at
256 colours, which is visible as banding in every gradient the tour passes over;
the webp has none. Encoded at quality 60.

Both files sit in the same release, so the gif stays reachable for anything that
still points at it.

The alt text dropped its duration claim. It said a 60-second tour, and the clip
runs 111 seconds.
2026-08-17 19:49:12 +02:00
Maurice e62e76713f docs(readme): repoint the tour gif, and tidy the top of the page
The 60-second tour has been a broken image since the org move. It points at
liketrek/TREK-media, and that repository does not exist; the release asset still
sits in mauriceboe/trek-media, which is where the src goes now. On the rendered
page the gif reports naturalWidth 0, so what a visitor sees under the badges is
an empty gap.

Around it, three things that were only layout:

The screenshot rows move below the feature list. Five full-bleed images ran back
to back before the reader got a single instruction, and the tour gif and the rows
show the same screens anyway. Motion first, then the summary graphic, then the
detail, then the stills.

The second horizontal rule goes with them, so one rule closes the header block
and the rest of the file separates sections the way it already did, with a break
and a heading.

The jump links had a separator dangling off both ends of the row.

The subtitle image says "your trip. your plan." while its alt text said "Your
trips. Your plan. Your server.", so anyone on a screen reader got a different
tagline than everyone else. The alt now reads what the image reads.

The tile SVGs gain width and height attributes. They only ever render through an
img that sets width, but without an intrinsic size of their own they fall back to
300px wide anywhere else.

The badge block stays as it is. Read on the rendered page rather than in the
markup, the two badge weights are a hierarchy rather than a clash.
2026-08-17 19:49:12 +02:00
Maurice 1f92d47452 Remove extra line in README.md
Removed an empty line before the subtitle picture.
2026-08-17 19:49:12 +02:00
Maurice 299c97ac9f docs(readme): correct the feature list against the code
Every line under "See all features" was checked against source, not against the
docs. What was wrong:

- 20 languages. There are 23; ca, sv and vi were missing from the list
- 27 scopes in 13 groups. There are 29 in 14
- 150+ tools. There are 199, and 4 prompts rather than the 3 named
- GeoJSON import. No parser exists; places.service.ts takes gpx, kml and kmz
- default coordinates under Units. No setting by that name exists anywhere
- day check-ins under Collab. The fourth sub-feature is a What's Next list of
  upcoming activities; nothing checks anyone in or out of a day
- Apple as an OIDC provider. The discovery check requires a userinfo_endpoint,
  which Apple does not publish, and the callback is GET only. Also only one
  provider can be configured at a time
- role-based access. trip_members has no role column; access comes from an
  admin-set matrix over 16 trip actions
- one-time invite links. A trip link is one reusable rotating token with an
  optional expiry; the use limit belongs to the admin signup invites
- iOS-style bag distribution. It is a fill bar against an optional limit, and
  bag tracking is an instance switch that ships off
- fullscreen with a splash screen. The manifest says standalone, and there are
  no splash assets; only Android derives one from the icon
- the service worker caching the API. /api is NetworkOnly on purpose, because
  Workbox keys by URL and cannot vary on the session cookie
- Atlas streak tracking. It counts consecutive years travelled, not countries
- approved school holiday overlays. They come from openholidaysapi.org and are
  capped to 16 European countries
- 50 MB per file. Video goes to 500 MB

Missing outright: the plugin system, which the file did not mention once, sandbox
and SDK included. Also Collections and AI Parsing as addons, public share links,
guest members, the ICS feed, Transitous, Overpass, OSRM route profiles, GPX
export, place enrichment, the audit log, multi-leg flights, and the offline
machinery past "caches tiles".

The Addons box duplicated Costs and Packing, which are the same switches as the
core lines above it. It is gone, and the default state sits on the line instead,
which is what a self-hoster actually needs to know.
2026-08-17 19:49:12 +02:00
Maurice 1f3567d928 docs(readme): free-standing cards, and drop the compound media query
Opera serves the stacked phone art to a full-screen desktop window whenever the
colour scheme is dark, and Chrome does not. The one unusual thing in the markup
was the first source, which combined two conditions:

    (prefers-color-scheme: dark) and (max-width: 700px)

GitHub only documents prefers-color-scheme in a README picture, so that source is
gone. No arrangement of single-condition sources can express "narrow and dark", so
width comes first and the colour scheme second: a phone in dark mode now gets the
light stacked art. Unreadable text is broken, a light card on a dark page is not.
grid-mobile-dark.svg goes with the source that referenced it.

The three fields also stop being compartments in one slab. They are three cards
now, each with its own wash, border and lift.

Type is a grade larger throughout because the old sizes were computed against a
1012px column. Measured on the rendered page, GitHub's README column is 838px, so
the label was landing at 14px and the caption at 13px.
2026-08-17 19:49:12 +02:00
Maurice 5440e76223 docs(readme): redraw the feature graphic, move the phone row up
The nine-tile grid gave every tile its own pastel, a two-colour headline, a ghost
icon behind it and the same eyebrow, headline, triplet rhythm, nine times without
a break. Three fields now, one glass panel each, one icon, and the detail stays in
the block underneath where it already lived.

Monochrome, and shipped as a light and dark pair through the picture element the
way the logo above it already is, so it stops being a bright slab on a dark page.
The ink is the app's own neutral accent (#111827 and #E4E4E7) instead of an
invented brand colour, and the panels follow the surface recipe from
client/src/styles/dashboard.css: transluent gradient, hairline border, highlight
on the top edge, soft lift. The backdrop blur cannot come along, an img-embedded
SVG sees nothing behind itself, so the wash it would have blurred is baked in.

Poppins is drawn as outlines for the same reason: that SVG may not load an
external font, so a font-family would have fallen back to whatever the reader
happens to have installed.

Also puts the phone row between the two desktop rows rather than after them.
2026-08-17 19:49:12 +02:00
Maurice 824834c8fe docs(readme): swap the animated logo for the static mark
The header opened on a 162-frame GIF, 878 KB across the light and dark pair for
an animation that runs before anyone has read a line. docs/ now carries the same
mark as the SVG the app itself ships, so the README header and the navbar show
one logo rather than two renderings of it, and the picture element keeps
switching it per colour scheme.

The GIFs stay in docs/ unreferenced, in case the animation is wanted elsewhere.
2026-08-17 19:49:12 +02:00
Maurice 92533afea3 docs(readme): replace the screenshot grid with the release showcase rows
The eight tiles were one screen each at 49% width, so the README opened on a
mosaic of unrelated crops. The showcase renders say the same thing in three
full-width rows: atlas, dashboard and the planner map, then collections, the
journal and costs, then the mobile lineup.

They ship as WebP. The same three frames as PNG are 14.3 MB, which every clone
carries forever and every README view downloads. At quality 92 the full 4292px
render is 1.1 MB for all three and a 1:1 crop is indistinguishable from the
source. .gitattributes learns the extension, since png, jpg and gif were listed
there already.

Nothing but the README pointed at the eight files, so they go with it.
2026-08-17 19:49:12 +02:00
Maurice 84d9fbff3c fix(scripts): rotate the instance-wide provider keys too
#1939 moved the Google Places and Unsplash keys into encrypted app_settings rows,
but the rotation script walks a hardcoded list of app_settings keys and the two
new ones were not on it. Rotating ENCRYPTION_KEY would have left them encrypted
under the old key, which reads back as null and silently drops the install to
OpenStreetMap with nothing in the log to say why.

users.unsplash_api_key was missing from the users column list for as long as the
column has existed. It matters now that the resolver reads that column as the
per-user fallback, so it goes in at the same time.

The script cannot import the canonical list, since it deliberately runs without
config.ts and without the container, so the copy is pinned by a parity test that
reads the script and fails when a name is missing from the app_settings loop.

Wiki: the rotation page listed the Maps key as per-user and did not mention the
app_settings rows an operator now has to carry across.
2026-08-17 18:33:43 +02:00
Maurice 1e166ab0d9 test(server): raise the coverage floor for the four domains this branch covered
Measured values with a point of slack, from scripts/coverage-thresholds.mjs:
atlas, audit, settings and unsplash all gained cases here.

Only those four. The script prints a block for every domain, and taking it whole
would have lowered src/nest/auth lines from 96 to 95 and tightened domains this
branch never touched, which would fail the open PRs for no reason.

unsplash is the one that needed the work rather than just recording it: dropping
the "any admin's key" fallback removed covered branches, so the parse-failure
ternary that was already uncovered started dominating the ratio and the gate
failed at 80.85% against a floor of 82%. Covered it instead of lowering the floor,
which put branches at 89.36%.
2026-08-17 14:48:23 +02:00
Maurice 0a7e460253 fix(planner): keep a parked car off the days between drop-off and pickup
A parking booking spanning the trip showed up on every day, sometimes in between
planned activities. It now appears on its first and last day only, with drop-off
and pickup wording, and contributes nothing to the days in between, not even a
badge in the day header. Applied to the day plan, the mobile timeline, the PDF
export and the shared trip view, so a shared link shows what the app shows.

Rental cars stay as they are. The report names the car's smaller tag as the thing
it does not want for parking, not as something to change, and a car's middle days
move into the day header, which is what people expect from a vehicle they are
driving around.

The distance connector between legs is keyed off the row that gets hidden, so
hiding it naively takes the "2 km" label with it, which is what #1504 was about.
There is a test that fails without that guard.

Each segment of a stopover flight or train can also carry its own booking
reference now, since airlines often issue one per flight rather than one per
booking. Legs already live as a JSON array on the reservation, so this is an
optional field per entry: no migration, no contract change, and a booking without
it renders exactly as before. Readable everywhere it can be entered, desktop and
mobile, through the same blur treatment other booking codes get, plus the PDF and
the calendar description. create_transport and update_transport preserve it too,
otherwise they would accept the field and silently drop it.

Closes #1937
Closes #1943
2026-08-17 14:48:23 +02:00
Maurice d156404b84 perf(atlas): bound the region layer to what is actually on screen
Interaction degraded the longer the map was used past zoom 5 and a reload fixed
it, which the report traced accurately: admin-1 geometry was fetched for every
country whose bounds crossed the viewport, the cache was never evicted, and the
whole accumulated set was torn down and rebuilt as one layer on every addition.
Panning past Canada or Russia added their geometry to every later rebuild for the
rest of the session.

Only the countries in view are rendered now, a signature over that set skips the
rebuild entirely while panning inside the same countries, and the cache holds a
dozen countries, never evicting one that is on screen or still loading.

One cause was not in the report and is probably the bigger one: a fresh renderer
was constructed on every rebuild. Leaflet registers a renderer as its own map
layer and removing the GeoJSON layer does not remove it, so the pane collected an
orphaned element per rebuild that kept updating on every pan for the rest of the
session, and at the country layer each orphan is a canvas of four times the
viewport area. Both renderers are created once per map now.

The region layer stays on SVG on purpose. Leaflet's stylesheet only makes SVG
paths mouse-transparent, so a canvas container would swallow the clicks meant for
the country layer below zoom 5.

Closes #1950
2026-08-17 14:48:23 +02:00
Maurice 54693474f1 fix(atlas): stop counting a connection airport as a country visited
The role IN ('from','to') filter from #1486 is correct and still in place, but it
cannot see the case this issue actually reports. Joining AirTrail legs into one
booking with a layover stop is opt-in, so legs imported separately store the hub
as the legitimate `to` of one booking and the `from` of the next, which no role
filter can tell apart from two real arrivals.

Pair them where the countries are counted instead: an arrival and a departure at
the same airport, in different bookings of the same trip, within 24 h of each
other are a connection, and neither endpoint counts as reached. 24 h is the same
window the importer already uses to chain same-PNR legs. Doing it on the read side
means the databases that already hold these flights heal on the next request.

Only flights pair, and never cancelled ones, so a rental car picked up at the
airport still counts its country (#1366). A booking that returns to where the
chain started is a return flight, and that check compares distance rather than
airport identity, so flying home into a different airport of the same metro area
does not erase the country you spent the day in. A country with a place logged in
it, or marked by hand, is untouched.

Closes #1535
2026-08-17 14:48:23 +02:00
Maurice 1a0834b866 fix(maps): resolve the Places key per instance, not per whoever is asking
Searching worked for the admin and 403'd for every other member. getMapsKey read
the calling user's own row first and then fell through to "any admin" via LIMIT 1
with no ORDER BY, so the admin who pasted the key hit their own row while everyone
else got a non-deterministic stranger's. That fallback is what CLAUDE.md forbids.

The Places and Unsplash keys bill the install, not the account that pasted them
in, so they become instance configuration: an encrypted app_settings row, resolved
as env -> instance -> the caller's own row, the same shape #1772 chose for the LLM
endpoint. The per-user column stays as the last step because /me/api-keys has
always accepted a personal key.

The trailing migration carries an existing install's key into the instance row so
nothing degrades to OpenStreetMap on upgrade, but only where that key already was
the whole install's: as soon as a second row holds one, it is left alone, because
the instance row outranks everybody's own column and an upgrade must not move a
member onto someone else's key and bill.

Two things from the same report:

The key test button probed with field mask places.displayName while the search
asks for places.rating and more, which is a separate SKU and can be restricted on
its own, so the button could pass while every search failed. It now probes the
resolved key with the search's own mask.

Saving API keys wrote no audit line. It now writes one settings.api_keys_update
row naming which fields changed, never their values, comparing cleartext because
the random IV makes every ciphertext look different.

Closes #1939
2026-08-17 14:48:23 +02:00
Maurice c9124c9b63 fix(places): only attach tags owned by someone on the trip
A tag belongs to a user, not to a trip, and the place create/update body is an
open record the Zod pipe does not police — so any tag id could be attached, and
the tag projection ships tags.user_id, which answers who owns a tag the caller
has no way to see. collection_place_tags had it verbatim.

Scoped to the roster rather than to the caller on purpose: a member tags a shared
place, another member re-saves it and, because the update is delete-then-insert,
sends that id back. Caller-scoping would drop a co-traveller's tag on every
foreign edit; roster-scoping keeps it. Collections resolve the same question
through their own member list.

No client sends a tags array today, so this only closes the hand-crafted path.
2026-08-16 23:17:50 +02:00
Maurice 244eb56e4c fix(budget): confine payers, split members and settlement parties to the trip
knownUserIds asked whether a user exists at all, not whether they are on this
trip, so any id on the instance could be made a payer or a split member. Both
read-backs join users, and the settlement projection carries the avatar through
avatarUrl, which passes an absolute URL straight out — for an OIDC account that
is the picture claim.

Payers and split members drop off-roster ids, as before, only against the right
set. Settlements cannot: they name exactly two parties and both columns are NOT
NULL, so dropping one would either break the constraint or move money to the
wrong person. Both wrappers refuse the write and return null, which lands on the
404 the update path already had. That answer is deliberately the same one a
missing settlement gets, so the endpoint says nothing about whether an id exists
— and it retires the foreign key error that used to surface as a 500 and drew
exactly that distinction.

createSettlement grows the 404 its sibling had; the MCP tool reuses the error
string already at hand. Money rules are untouched: a settlement is a transfer, so
refusing one cannot unbalance the sum, and the cent arithmetic is unchanged.
2026-08-16 23:17:50 +02:00
Maurice 0337ebbbd2 fix(assignments): confine day participants to the trip roster
setParticipants took the ids on trust. The caller is proven to be on the trip and
the assignment is proven to sit on it, but nothing said the same about the ids in
the body, and the read-back joins users — so a stranger's name and avatar came
back to the writer and went out over the assignment:participants broadcast to
everyone else in the room.

Takes the tripId both callers already hold and filters against the roster. Same
drop-rather-than-reject rule as bag members and reservation travellers: the
participants box posts the whole list on every edit, so a trip whose membership
changed underneath it would otherwise be stuck.

ASG-SVC-028 covered a foreign key error rolling the replace back. The filter
takes an unknown id out before the insert can reach it, so that path is gone and
the case now pins what happens instead: a list of nothing but strangers clears,
like the empty list ASG-SVC-018 already pins.
2026-08-16 23:17:50 +02:00
Maurice 1eb29f8b77 fix(trips): confine assignee writes to the people on the trip
Holding packing_edit or todo_edit says you may edit this trip's lists, not that
any user id you name belongs to it. Both updateCategoryAssignees loops wrote the
body's ids straight through, and both end by re-selecting through a JOIN on
users — so an id from anywhere on the instance came back with a display name and
an avatar attached, in the response and in the WebSocket fan-out to the room. The
packing item recipient writes had the same gap.

packing already had tripRosterIds for exactly this and used it only for bag
members; its doc comment said "assigned to a bag", which is probably why the
other three writes grew up without it. The query moves to DatabaseService next to
canAccessTrip, which asks the same question as a predicate, and the helper now
delegates. Every service that needs it already injects DatabaseService.

Off-roster ids drop rather than 400: a copied trip carries assignee ids over
before its members exist, and rejecting the whole request would stall the picker
there. Guests are unaffected — a guest is a trip_members row, so the roster
includes them, which is the point of #1362.
2026-08-16 23:17:50 +02:00
Maurice 5bc041cc77 fix(journey): escape the tooltip text on the Leaflet maps
A string handed to bindTooltip becomes innerHTML. JourneyMap passed an entry
title and a track name through untouched; the GL renderer beside it escapes
both already, and JourneyPublicPage imports the Leaflet one directly. The atlas
bucket-list tooltip had the same gap.

Also pins the escape on MapViewGL's photo marker. Its Leaflet twin has
FE-COMP-MAPVIEW-074 for this, but the GL side was only ever exercised with
benign values, so escapeHtml could have been dropped there without a test
noticing.
2026-08-16 21:57:21 +02:00
Maurice c8607ba234 fix(places): pin the image and website URL fields on the way in
image_url and website were bare strings in the contract, and the create/update
bodies are open records, so the Zod pipe never looked at either. Both leave the
database for something that treats them as a URL: the thumbnail is interpolated
into hand-built marker HTML, and the homepage is handed to window.open, where a
javascript: value would run in this origin rather than opening a page.

Both are allow-listed on the way in now, next to hexColorSchema and checked in
the handler the way validateRouteColor already is, on all three write surfaces
so REST, the plugin RPC and MCP agree on what they accept. The entity schemas
stay open: rows written before this still have to round-trip.

safeHttpUrl covers the read side for exactly those rows, the same split
safeHexColor uses, and the window.open calls it guards pick up
noopener,noreferrer while they are being touched.
2026-08-16 21:57:21 +02:00
Maurice 0f0bf50cd3 fix(assignments): guard the per-assignment routes like the day routes
AssignmentOpsController declared @RequirePermission('day_edit') on its
mutations but carried only JwtAuthGuard. TripAccessGuard is what reads that
decorator and what resolves :tripId, so on this controller the permission
markers were metadata nobody looked at and the trip in the URL was never
matched against the caller. Its sibling, DayAssignmentsController, has had the
pair since the migration.

The two participants handlers also went straight to the service with the id
from the path, unlike move/time/transport, which ask getAssignmentForTrip
first. They ask now too.
2026-08-16 21:57:21 +02:00
Maurice ff27624e02 fix(reservations): scope the per-day position write to the trip
updatePositions has two branches. The legacy one updates
`WHERE id = ? AND trip_id = ?`; the per-day one, three lines below it, bound
the caller's ids straight into reservation_day_positions and never touched the
tripId it was handed. That table has no trip_id of its own and its two foreign
keys only require the ids to exist, so nothing further down caught a pair that
did not belong together.

The trip now comes from the join, so the row materialises only when the
reservation and the day agree on it. Doing that in the statement rather than as
a pre-check also turns a stale id into a quiet no-op instead of a foreign-key
error surfacing as a 500, and lets an omitted day_plan_position bind the way
the wire contract has always said it may.

reorder_reservations reports a day that is not on the trip, the way its sibling
tools in that file already do, and a migration clears rows whose reservation
and day disagree.
2026-08-16 21:57:21 +02:00
Maurice 688eea23a4 fix(dashboard): keep the trip card actions on the cover and give them contrast
The edit/duplicate/archive/delete buttons sat at opacity 0 until the card was
hovered, over a white .18 fill. Two people in a row said they never found how
to unarchive or rename a trip: invisible at rest, and white-on-white over a
light cover even while hovering.

They stay on the card now at .92, the fill is a dark scrim so the white icons
carry against any photo, and the icons get a heavier stroke. The status pill
and the hero badge move to the same scrim so the cover overlays read as one
set. An archived card shows the restore icon rather than the archive one,
which is what mobile already did, and every button gains a title next to its
aria-label.
2026-08-16 21:57:21 +02:00
jubnl b1e4c830b2 fix(immich): strip leading / in immich url (#1948) 2026-08-15 23:05:44 +02:00
jubnl 2bffee8fd4 fix(auth): preserve remember-me when password change re-issues the session (#1927) 2026-08-15 22:48:22 +02:00
jubnl 72221a2e45 feat(auth): sliding session renewal past half the token lifetime (#1927) 2026-08-15 22:48:16 +02:00
jubnl 61e03fdfde feat(oidc): honor remember-me in SSO login (#1927) 2026-08-15 22:48:03 +02:00
Maurice cb41d082be feat(admin): make an admin section linkable with ?tab=
The admin page always opened on Users, whatever the address said. A support
reply, an onboarding mail or a bookmark could point at the page but not at the
panel it was about, so every one of them ended with "then click X on the left".

Read once for the initial value and written back on each change, so the address
bar keeps saying where the reader is. Replaced rather than pushed: eleven tabs
would otherwise fill the back button with the history of a single page.
2026-08-15 18:07:21 +02:00
Maurice a6c27aac51 fix(admin): stop asking for OIDC config where the route is closed
On a centrally administered install /api/admin/oidc answers 403, and the admin
page asked anyway. The promise was caught, so nothing broke, but the request
still went out and still printed a red 403 in the console of every admin who
opened the page. Swallowing a rejection hides a failure from the code, not from
the person reading the console.
2026-08-15 18:07:21 +02:00
Maurice f2329c9a63 feat(client): let a managed surface attach as an admin tab
The nav-entry seam was the wrong shape for what actually attaches here. A
subscription surface is about the install, not about a trip: /admin is already
behind an admin check, already grouped, and already where somebody looks to
change how their instance works. A top-level nav entry put it beside My Trips
for a screen opened once a month by one person.

Two lines in AdminPage rather than an overlay replacing the file: the tab list
gets the extra entries appended per group, and the body renders whichever one is
active. Appended rather than inserted because PageSidebar needs a group's tabs
contiguous.

The route and nav-entry seams stay. They are the right answer for a surface that
genuinely is a page, and both remain empty here.
2026-08-15 18:07:21 +02:00
Maurice 887e6c5026 feat(client): let a managed route require an admin
Hiding the nav entry is not access control: the path stays typeable, and a screen
about how the install is run should not open for somebody who was invited to one
trip. ProtectedRoute already knows how to refuse — the attachment point just was
not asking it to.

Optional, so a route that does not set it behaves exactly as before. It sits
next to adminOnly on the nav entry rather than being derived from it, because
the two answer different questions: who is offered the door, and who may walk
through it.
2026-08-15 18:07:21 +02:00
Maurice 7c378c574f feat(client): let a managed nav entry be admin-only
The attachment point hands the two nav bars a static list, so anything attached
there showed up for every user of the instance. What belongs there is the
operator's side of the install, and a trip's other travellers are on the instance
as guests of the person who runs it — an entry about how it is run is noise to
them.

`adminOnly` is optional and absent means "for everyone", so nothing that exists
today changes meaning. Both nav bars go through the same exported filter rather
than each writing the rule, since a rule written twice is a rule that will differ.

Still inert here: the list is empty, and the tests pin that an empty list stays
empty for either kind of user.
2026-08-15 18:07:21 +02:00
Maurice 05315ef7d9 fix(planner): pin the clock in the opening-hours test
FE-PDC-019 asserts that the collapsed row shows one specific line, but the row
shows the current day in the place's timezone — and the fixture gives Saturday
different hours from the working week. So the test passed Monday to Friday and
took the build down every weekend, which reads as a broken feature rather than a
test that forgot to say which day it is standing on.

Pinned to a Wednesday, with the Saturday case kept as its own test rather than
thrown away: it is the one that proves the day lookup happens at all, and it is
the exact case that was failing.
2026-08-15 18:07:21 +02:00
Maurice 3a0604b3f5 feat(config): put the operator-owned surfaces behind TREK_MANAGED (#1946)
* feat(config): put the operator-owned surfaces behind TREK_MANAGED

The switch landed last release with nothing behind it. This is what it turns
off, plus the four fixes that came out of walking every one of these surfaces.

Nothing here changes anything for an install that does not set the flag. That
is not a hope, it is what managed-inert.test.ts asserts: the mode stays off for
any unparseable value, no other variable can turn it on, every settings key
stays writable, and splitManagedKeys hands back the same object rather than a
copy so no write path can quietly diverge on self-hosted installs only.

WHAT THE MODE WITHHOLDS

Fourteen routes carry @ManagedForbidden: key validation, static MCP tokens, JWT
rotation, the OIDC admin pair, restore and auto-backup, plugin sideload and
reload, the Ollama model list and pull, and the SMTP test send. Each names the
reason it is not the instance admin's, and the reason is mandatory.

Eighteen settings keys are filtered instead of refused. SMTP, the OIDC toggles
and the WebAuthn pair share one handler with keys the admin does own, and the
llm_* family is reachable through per-user settings, the instance defaults and
the addon config alike, so a route-level refusal would be both too coarse and
incomplete. A locked key is skipped and reported as managed_keys; the rest of
the save goes through. Refusing the request would take the registration toggles
down with the SMTP block.

GET /api/auth/me/settings stops returning the three API keys in the clear. It
is the only endpoint that decrypts a stored credential back to the caller, which
is fine when the admin pasted it in and wrong when the operator supplied it.

Two multipart routes check in the handler rather than the guard: a guard throws
before the parser drains the body and the client gets an ECONNRESET instead of
the 403 (PROFILE-015). They still carry the marker so the boot gate sees them.

WHAT KEEPS IT FROM DRIFTING

validate-managed-routes.ts refuses to boot on a marking that is not on the
reviewed list, a list entry whose route lost its marking, or a marking with no
reason. The third case has no equivalent in the guard gate next door and is the
one that matters most: a blank reason destroys the only record of why a control
was taken away, and nothing recovers that later. It found two wrong entries in
my own list while I was writing this.

managed-keys.test.ts asserts that every key in ADMIN_SETTINGS_KEYS and
DEFAULTABLE_USER_SETTING_KEYS belongs to exactly one of two sets. A key added to
either list belongs to neither until somebody decides, and that is the failure
this prevents: the lists grow, the mode does not.

PLACES_API_BASE AND PLACES_API_KEY

The nine Places endpoints spell out googleapis.com, so an install that wants
those calls to leave through something of its own has no way to say so. One
variable, substituted where every call already funnels through. A second one
supplies the key, and when it is set the users table is not consulted at all —
a per-user key would route around whatever the endpoint counts, and those
columns are readable by any admin of the instance.

FOUR FIXES THAT ARE NOT ABOUT THE MODE

An admin setting somebody's password now bumps password_version and clears
their MCP tokens. An admin sets a password for one reason, and until now
verifyJwtAndLoadUser kept accepting every cookie the intruder already had: the
one action taken to lock them out was the one action that did not.

The four OIDC provider calls go through the SSRF guard. Three are worse than
they look — the token, userinfo and JWKS URLs come out of the discovery
response, so they are chosen by the other end, not by an admin. safeFetchLlm is
renamed safeFetchAdminConfigured and keeps its old name as an alias, because it
is the variant that allows loopback and LAN: a self-hoster with Authentik on
localhost:9000 must keep working. The token exchange follows no redirect at all,
since one would hand client_secret to a second host.

DELETE /api/admin/users/:id/mfa clears another account's TOTP. The passkey half
has existed since passkeys landed; the TOTP half never did, which left
"somebody on the trip lost their phone" with no answer short of an operator in
the database. Never for the caller themselves.

TREK_PLUGIN_PERMISSIONS=off refuses to boot when the install is centrally
administered. A warning is right for a machine its owner chose to trust; where
the person who set the flag and the people whose data is in the database are
not the same person, fail closed.

TEXTS

Four strings lose the phrase "self-hosted" because they describe somebody
else's software — an Ollama endpoint, an AirTrail instance, a service a plugin
talks to — and read better without it in both modes. The one string that
describes TREK itself keeps it, and the mode picks a second key instead, so
self-hosters lose nothing.

A grep gate in shared/ keeps the canonical locale honest, because the parity
check compares the file set and the top-level keys and never looks at a value:
re-wording a string is invisible to CI today, and so is putting it back.

* feat(maps): supply the map credential with the instance when centrally administered

MAPBOX_ACCESS_TOKEN joins PLACES_API_KEY as an operator-supplied value.
SettingsService injects it when the settings are read, and picks GL as the
provider if the reader is still on Leaflet, since a token is always present.

The key moves from the customer set to the locked set. It is the one locked key
whose value is public by design — a pk.* token has to reach the browser to be
useful — so this is not about secrecy: a per-user save landing on top of the
instance token would only break the map for that user. Both settings fields for
it go away with it.

* fix(auth): count the operator key when reporting whether Google features work

has_maps_key only looked at the users table, but getMapsKey prefers the
PLACES_API_KEY env over anything stored there. On an install that supplies the
key that way and has an empty column, every lookup worked while the client was
told there was no key and hid the search, the autocomplete and the place photos.

Found by walking the running instance rather than the code: the map came up,
the search box did not.

* feat(admin): hide the settings a centrally administered install cannot act on

A control that answers 403 is worse than no control: the AI section rendered the
refusal as "Request failed with status code 403", which tells an admin they broke
something rather than that the decision is not theirs.

So the surfaces go rather than being disabled in place:

- AI parsing and AirTrail leave the addon list, and the AI addon route now
  refuses outright. The endpoint, the model and what a document costs come with
  the instance, so there is no instance decision left — not even whether it runs.
- Immich and Synology Photos leave it too. Both connect to a server the reader
  runs at home, and there is no route from here to one.
- The SMTP block, the admin webhook and ntfy targets, and the passkey RP ID and
  origins follow the same rule: they configure something the operator owns, and
  a wrong RP ID on a shared parent domain reaches past this instance entirely.
- The About tab goes. It is about the project — what TREK is, where to file a
  bug, where to support it — and is aimed at the person running the software.
  The version and the link to the source move to the sidebar footer, on both the
  desktop page and the mobile twin, because AGPL 13 wants that offer wherever
  people reach the software over a network.
- The update check stops asking GitHub and reports nothing available.

Separately, the version footers lose the "self-hosted" suffix everywhere, not
just here. It read as a label on the software rather than on the install, and
the version alone says what it needs to.

* feat(config): refuse to boot centrally administered without an at-rest key

Measured on a running instance rather than assumed: with ENCRYPTION_KEY unset,
the at-rest key falls back to a file in the data volume, and backupService
bundles that file into every archive it builds. The archive is downloadable by
any instance admin, so the encryption ships with its own key in the same zip.

The skip in backupService already handles this — it only omits the file when the
key came from the env — but nothing made sure the env actually carried one.
Provisioning sets it; forgetting it was silent.

validateEnvAtBoot now carries a second kind of check next to the schema: cross-
field rules that only apply with the flag set, for combinations where every value
is individually valid and the result is wrong. Empty without the flag, so an
install that never asked to be centrally administered cannot meet it.

* feat(client): add an attachment point for install-specific screens

Two empty exports on the client, an empty module on the server, and the four
places that read them. Nothing here adds a route, a nav entry or an endpoint —
the arrays are empty and the module contributes nothing, which managed-ext.test
pins so it stays that way.

Read unconditionally, never behind a flag. On the server a conditional import
would tie the module graph to an environment variable, and the graph is built
once per app while the flag is read live; on the client an empty array costs
nothing to spread, so every build walks the same path instead of one build
walking a path nobody tests.

Anything attached here follows the same rules as the rest of src/nest and
src/components: a real module with a controller and a provider, bodies through
the Zod pipe, routes under the global guards, tests.

* test(shared): move the wording gate out of src so shared typechecks again

CI installs shared with its own dependencies only (npm ci --workspace shared),
so there is no @types/node and the `node:fs` import inside src/ failed to
compile. The tests passed the whole time — vitest supplies the module — which is
why this only showed up in the typecheck step.

The walk moves to scripts/, next to i18n-parity.mjs, and the spec imports it the
same way that one does. It also runs on its own now
(`node scripts/i18n-selfhost-wording.mjs`), which is what you want when CI names
a line you have to go find.

* test(server): repair the call sites typecheck:tests caught

tests/ sits outside the build tsconfig, so none of this showed up in a green
test run — which is exactly the gap `npm run typecheck:tests` exists to close.
Three kinds, all mine:

- Seven `import type` paths for RuntimeEnvService carried one `../` too many.
  Type-only imports are erased, so vitest never resolved them and nothing failed.
- VersionCheckJob and PluginsController grew a constructor parameter and eleven
  hand-wired `new` calls kept passing the old count. The argument arrived as
  undefined and no assertion happened to read it.
- ConditionContext grew `managed`, and the shared context object in the
  conditions suite did not.

The condition also had no test of its own, so it has one now: both directions,
since the registry uses `is: false` to keep a notice away and a notice that only
belongs on a centrally administered install would use `is: true`.
2026-08-15 16:42:27 +02:00
Maurice bfb3bcd63c feat(config): mark an install as centrally administered
Some installs are run by somebody other than the person who administers them.
The operator owns the credentials, the configuration and the upgrade schedule,
and the admin of the instance is a user of the result rather than the one who
set it up. Nothing in the app knows that today, so it keeps offering settings
whose values are not the admin's to change, and the honest answer to half of
them would be a 403 the UI never anticipated.

TREK_MANAGED says it once. It is a single boolean and the surfaces that care
read it themselves, so adding one never comes back to the config layer.

Runtime-toggled, in the same class as DEMO_MODE, rather than a registerAs
token: roughly sixty tests mutate env mid-lifetime, and a value frozen per
built app breaks all of them. `=== true` on the way in, so an unset, empty or
misspelled value means off — a managed install sets it deliberately, and a typo
must not quietly take settings away from an admin who never asked for it.

The guard keys on a decorator, not on a path. MfaPolicyGuard documents where
the two literal path lists it replaced ended up: they stopped being maintained,
and every endpoint added afterwards silently fell on the wrong side. A marker
on the handler cannot drift away from the handler. It carries a mandatory
reason, which is for the next reader of the source rather than for the client,
so the marks stay auditable.

It registers third, after the auth and MFA guards, so a stranger still gets the
401 those two would have given them instead of a 403 that would confirm the
route exists. And it checks the mode before it touches metadata, because it
runs on every request of every install and almost all of them are self-hosted.

One 403 body, shared by REST and MCP. DEMO_MODE grew two texts for the same
condition and neither can be changed now without hunting for the other.

The flag reaches the client through app-config so the UI can stop offering what
the server would refuse anyway, but it is deliberately not persisted the way
demo_mode is: two installs can share a browser profile, and a stale "this one
is managed" would take settings away on an install that never set it.

No route carries the marker yet. The per-domain marks follow. Unset, which is
every self-hosted install, nothing about this changes anything.
2026-08-15 11:45:11 +02:00
Maurice c9193e0621 fix(maps): answer a keyless place lookup with an empty result, not a 400
An instance without a Google key already runs on the OpenStreetMap stack: search
falls back to Nominatim, autocomplete does too, photos fall back to Wikimedia.
The two details lookups were the exception. Both threw a 400 with "Google Maps
API key not configured" the moment they were handed a Google place id.

A Google id has no OpenStreetMap equivalent, so without a key there really is
nothing to resolve. That is an empty result, not a caller mistake. The callers
already treat a null place as a miss and fall back to the text-search path, so
they were doing the right thing with the wrong signal, at the cost of an error
round-trip and a logged exception each time.

It bites whenever a place that was saved while a key existed is opened after the
key is gone, and on any instance that never had one but imported Google-sourced
places from a shared trip.

The declared return type said the place was non-nullable while the OSM branch
could already return null and the wire contract has always had it nullable. It
now says what it does.
2026-08-14 23:42:51 +02:00
Maurice f02a4b5654 feat(maps): bill a place search as one session instead of per keystroke
Google charges autocomplete per request unless the requests carry a session
token. With one, the keystrokes of a single search and the details lookup that
ends it are charged together, which is also how a user experiences it: they
looked up one place.

TREK sent no token at all, so every debounced keystroke was its own billable
request. On a busy trip that is the largest line on the Places bill, and it is
paid by whoever owns the key, which for a self-hosted instance is the person
running it.

A session opens with the first suggestion request and closes when the user picks
a result. It has to close on the abandoned paths too: a token carried into the
next search would merge two searches into one billed session and scope the
suggestions wrongly. So the search box clearing it, the text-search fallback and
the error paths all end it.

The token is validated on the way in. The autocomplete body goes through the Zod
pipe, the details query does not, so the controller shape-checks it there.
Anything that does not match Google's URL-safe 36-character form is dropped
rather than forwarded, which falls back to per-request billing instead of
breaking a lookup.

A cached details hit never reaches Google and so never closes its session. That
is billing-neutral: an unclosed session is charged as a plain autocomplete
session, which is still cheaper than the per-request behaviour it replaces.
2026-08-14 23:42:51 +02:00
Maurice f23de88b74 fix(packing): authorize every single-item path against the item, not just the trip
A packing item is Common, Personal or Shared with named recipients. listItems()
always applied that rule, but every path that touches one item resolved it by
trip id alone, so trip membership plus packing_edit was enough to reach an item
the member could not see. Update, delete, clone, the contributor routes and the
sharing route all shared the lookup, and the same service methods sit behind the
MCP tools and the plugin RPC, so all three transports carried the gap.

The update was a read as well: it answered with the enriched row, which hands
back the name, category, quantity, weight, the owner's username and the recipient
list of an item the caller was never shown.

Two flows laundered restricted items into places they do not belong. Saving a
trip as a template read every row in the trip, so a template captured the names
and categories of everyone's Personal items. Copying a trip did the same and
re-inserted without is_private/owner_id, so both fell back to the column defaults
and another member's Personal or Shared item reappeared in the copy as a Common
item. That path needs only trip_create and trip access, not even packing_edit.

There is now one visibility rule in the service and every single-item lookup goes
through it. The actor is threaded through the HTTP, MCP and plugin-RPC entry
points, and a missing actor denies rather than falling through unfiltered. An
item the actor may not see is reported as missing, not as forbidden, so the
routes cannot be used to confirm that an id exists.

Two deliberate contract changes fall out of that. The sharing route answers 404
instead of 403 when the caller cannot see the item at all; a visible item they do
not own still answers 403. And a template or a trip copy now carries the Common
list plus the actor's own items, with a restricted item staying restricted and
owned by the copier.

Reported privately by Chichi (@vickyzer027-hash).
2026-08-14 22:09:21 +02:00
Konstantinos Thermos 3e52695124 Merge pull request #1932 from liketrek/feat/comaps-integration
feat(planner): open places and day routes in CoMaps
2026-08-14 19:03:35 +03:00
Maurice 4cab17a6b9 fix(airtrail): read and write the passenger list under both its names
AirTrail 3.12.0 renamed the flight's passenger list from `seats` to
`passengers` and moved flightReason from the flight onto each passenger. There
is no alias in either direction: a 3.12.0 instance returns `passengers` and
`null` for `seats`, and its save schema accepts only `passengers`.

TREK read `raw.seats` everywhere, so against a 3.12.0 instance it found nothing.
The seat never made it into the import, and because a pull rebuilds the metadata
from the AirTrail payload, flights that had already been imported lost the seat
they had. That is the report. Flights whose sync is switched off were never
re-pulled, which is why theirs survived.

Two more breaks came from the same rename and were not reported. The flight
reason stopped importing for the same reason. And the writeback pushed the seat
under `seats`, which the new schema strips, so a seat edited in TREK was quietly
dropped on the way out; it did not fail loudly because the spread of the fetched
flight carried AirTrail's own `passengers` through untouched.

Both shapes are handled rather than switched between, because instances on 3.11
and older are still out there: reads take `passengers ?? seats`, and the save
body carries the manifest under both keys. Neither schema is strict, so each
version keeps the key it knows and strips the other.

The snapshot hash deliberately keeps feeding the manifest in under the same key.
An unchanged flight on an older instance therefore hashes exactly as before and
does not re-sync, while a 3.12.0 flight hashes differently once and pulls the
missing seat back in. There is a pinned-hash test guarding the first half.

Verified against real instances rather than the docs: a 3.11.1 and a 3.12.0
container, seat imported from each, a seat edited in TREK written back and read
out of AirTrail again, and the pre-fix build failing the same checks on 3.12.0.

Closes #1931
2026-08-14 14:32:35 +02:00
Konstantinos Thermos 0e7c516279 Merge branch 'dev' into feat/comaps-integration
# Conflicts:
#	client/src/components/Planner/DayPlanSidebar.tsx
2026-08-14 15:05:00 +03:00
Maurice 681fe1fee3 fix(dashboard): keep hotels out of the upcoming reservations widget
A stay covers a range rather than a moment, so one week in a hotel would hold a
slot in a six-entry widget for the whole week and push out the bookings that
actually happen on a day. The accommodation belongs to the day plan, which shows
it across its span.

They never worked here either, which is what the report is about. A hotel keeps
its dates on the linked stay, not on the reservation, so the two ways one can
exist behaved differently:

The booking form writes reservation_time = NULL and sends no day_id, so a
form-created hotel matched neither arm of the date test and was silently absent.
That is the "incomplete" half.

The accommodation panel auto-creates its booking with day_id and with the start
day's date stamped into reservation_time. That row did show, but updateAccommodation
syncs only metadata and the confirmation number to its linked bookings, so moving
the stay left the stamp untouched and the widget kept the old date. That is the
"no effect when I change the date" half.

The type test is NULL-safe: the column defaults to 'other' but nothing stops a
NULL, and a bare `type != 'hotel'` evaluates to NULL there and would have dropped
those rows from the widget instead.

Closes #1934
2026-08-14 13:34:13 +02:00
Maurice f43b56a745 fix(collab): let the empty chat state fill the panel
The empty state went in without flex-1 while the message list it stands in for
has it, so it was only as tall as its own content. The composer rode up to sit
directly under the mascot with the rest of the panel left blank, which reads as a
layout that failed rather than a chat waiting for its first message.
2026-08-14 09:53:53 +02:00
Maurice 79c460fd38 fix(map): keep the layer switcher clear of the sidebar edge
At a 12px gap the pill's own shadow ran into the sidebar it sits beside, so the
two read as one surface rather than a control floating on the map. 20px.
2026-08-14 09:53:53 +02:00
Maurice 97c725ea14 feat(planner): collapse the three export buttons into one dialog
PDF, ICS and GPX each had their own button in the day-plan toolbar, and two of
them carried a hover menu of their own. That is five controls' worth of width in
a pane the user can drag narrower, so the exports were the first thing pushed off
the edge, and a hover menu is not reachable at all once its button is gone.

One button opens a dialog instead: the day plan as a PDF, the bookings as a
calendar (a one-off .ics or a live subscription) and the map data as GPX in the
three scopes a device wants. Every row reports that it is working, which the PDF
export needed most: it fetches a 226 kB builder before it renders anything, and
until now the toolbar button simply sat there.

Two strings the old ICS menu had hardcoded in English, the download and the
subscription, come from the locale files the phone export sheet already used.

The toolbar no longer needs the icsHover flag it only held to keep its menu open,
so that comes out of the sidebar's state as well.
2026-08-14 09:53:53 +02:00
Maurice 974c240043 fix(planner): give the reservation time its own chip
The status, the time and the flight or train number sat inside one pill strung
together on a middle dot, and the dot was baked into the translation itself:
planner.resPending ended with " · " in all 23 locales. That made the row read as
one run-on sentence, and it meant the separator could never move without a
locale change.

They are separate chips now, same tint so they still belong together and nowrap
so the time cannot break away from the status it qualifies. The separator is out
of the locale files, which also retires the regex the transport detail modal
needed to strip it back off.
2026-08-14 09:53:53 +02:00
Maurice 460b215e69 fix(collections): name the phone label chip after what it opens
The chip that opens the label manager was labelled with collections.linkLabel,
which is the caption of a link in the list editor. English calls that one "Label"
so it happened to read correctly there, but German got "Bezeichnung", Russian
"Название" and Ukrainian "Підпис": wrong word, and long enough to push the filter
row out of its panel. It uses collections.labels.title now, which is the manager
it opens and is shorter in every locale.

collections.select was the other one over the edge. It is only an aria-label on
the desktop filter bar, so the eight locales whose word did not fit are on a
shorter one now.

The truncate on the chip labels never worked: a flex item keeps min-width:auto,
so the row grew past the panel instead of shrinking its chips. With min-w-0 a
long label name or a bumped-up text size ellipsises inside the panel.
2026-08-14 09:53:53 +02:00
Maurice a234653b29 fix(collections): let the phone dock add a place inside a list
The centre "+" matched /collections exactly, so it only knew it was on the
collections screen while no list was selected. Picking one moves the route to
/collections/:id, the match failed, and the action fell through to the last
branch of useCreateAction, which creates a trip on the dashboard. That is the
state the screen is normally used in, so for most people the button never added
a place at all. It also explains the report that there is no way to add one: the
workaround, adding from "All saved" and assigning afterwards, is the only path
that happened to still work.

The handoff carries the id rather than dropping back to /collections. Otherwise
adding from inside a list would land on the union and the sheet would ask for the
list the user is already looking at.

Closes #1930
2026-08-14 09:53:53 +02:00
Konstantinos Thermos 8065af28ef feat(planner): open places and day routes in CoMaps
Add CoMaps, the offline OpenStreetMap navigator, alongside the existing
map integrations. A geolocated place gains a CoMaps entry in its
navigation menu, and a day's route toolbar gains a button that hands the
day's bookended stops to CoMaps in that day's travel mode.

CoMaps' route link carries only a start and a destination, so a two-stop
day goes as a route and a longer one as named pins rather than silently
losing the stops in between. Links use the https form so a device without
CoMaps installed lands on comaps.at instead of a dead scheme.

The hotel-bookend logic behind the Google Maps export was duplicated
between desktop and mobile; both now share dayExportStops so the two
links cannot disagree about which hotel legs are real.

Addresses discussion #1904
2026-08-14 10:24:14 +03:00
Maurice 041997e5e6 fix(feed): keep the TZID when a clock already carries seconds
fmtDateTime's time-only branch appended '00' for seconds no matter what it was
given. The booking import stores what KItinerary hands it, which is '10:00:00',
so the value came out 17 characters long. dtLine only attaches the TZID to a
well-formed 15-character value, so those events lost their zone and went out as
floating times, which is the #1453 regression by another route.

It pads the way the ISO branch above it already does, so every caller is covered
rather than only the rental markers.
2026-08-13 16:10:25 +02:00
michael-bohr 7d580efce9 feat(feed): car rental pickup and drop-off events in the ICS feed
Rentals now emit two additional timed markers, Pickup and Drop-off, the
same additive shape hotels already get for check-in/check-out (#1721,
last unported piece). The rental's existing single-block event is
untouched.

Sides are derived in priority order: the endpoint role (from = pickup,
to = drop-off, reusing the already-loaded endpoints map); if no
endpoint on the reservation carries either role, positional first/last
by sequence (only once there's more than one endpoint, so a lone
endpoint is never both sides); otherwise reservation_time /
reservation_end_time, since imports frequently carry only that window.
Per side, an event only fires once a usable date and clock resolve;
zone resolution prefers the endpoint's stored timezone, then its
coordinates, then the reservation's linked place.

Follows the hotel `marker` closure's pattern closely (same esc/uid/
dtLine/isDate/isTime/usedZones usage, same Pickup:/Drop-off: summary
convention, same per-kind UID shape) without touching the hotel path
itself.
2026-08-13 16:10:25 +02:00
dependabot[bot] 54a3c8d50c chore(deps): bump @hono/node-server from 1.19.14 to 1.19.17 (#1918)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Revert "chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)" (#1694)

This reverts commit 94736112cf.

* chore(deps): bump @hono/node-server from 1.19.14 to 1.19.17

Bumps [@hono/node-server](https://github.com/honojs/node-server) from 1.19.14 to 1.19.17.
- [Release notes](https://github.com/honojs/node-server/releases)
- [Commits](https://github.com/honojs/node-server/compare/v1.19.14...v1.19.17)

---
updated-dependencies:
- dependency-name: "@hono/node-server"
  dependency-version: 1.19.17
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: jubnl <66769052+jubnl@users.noreply.github.com>
2026-08-13 16:08:45 +02:00
Maurice 5f4fa30416 docs: say where the outbound proxy switch actually lives
The variables only take effect when Node runs with NODE_USE_ENV_PROXY=1, and
that is set in the image. This page also covers Helm, Unraid and source
installs, where nothing sets it, so following it there changed nothing.

Also recommend NO_PROXY. Without it the proxy sees every request TREK makes to
itself, including the container health check.
2026-08-13 16:07:28 +02:00
Cynosure159 eecde11061 docs(docker): document outbound proxy configuration 2026-08-13 16:07:28 +02:00
Cynosure159 84a84078f6 feat: enable environment proxy support in container 2026-08-13 16:07:28 +02:00
Ida Rogalska 0d737c2466 fix(dayplan): resolve per-leg transit departure time within its own day
Review of the previous commit surfaced that the departure-time lookup keyed a
trip-wide coordinate index, so a POI revisited on another day (a supported
pattern) leaked the other day's time into the prefill. Resolve the departure
time from the leg's own day instead; the name index stays trip-wide (a name is
coordinate-stable).

Also from review: wire the per-leg option on the mobile sidebar too (it was
desktop-only), drop the now-dead route-leg fallback in the outgoing menu opener,
and accept single-digit hours in the time normalisation. Adds a regression test
for the revisited-stop case and pins the hotel-bookend fallback time.
2026-08-13 16:05:20 +02:00
Ida Rogalska ecea39d912 feat(dayplan): offer public transport on any connector leg (#1281 follow-up)
The per-segment travel-mode menu now carries a "Public transit" entry on every
editable connector: stop-to-stop legs and the boundary hotel/booking bookends
alike. Choosing it opens the existing transit search pre-filled with the leg's
origin, destination and the stop's departure time, instead of drawing an OSRM
road route.

A connector only renders when both endpoints are located, so the option is
inherently limited to legs that can actually be searched. A leg's coordinates
are resolved back to named, timed points from the trip's places, accommodations
and booking endpoints. TransitSearchPanel gains an initialTime prop so the
departure time can be seeded.
2026-08-13 16:05:20 +02:00
Maurice 1aa1aea57e ci: retarget Dependabot pull requests onto dev instead of labelling them (#1926)
Dependabot cannot be pointed at dev. \	arget-branch\ in dependabot.yml applies to
version updates only, and security updates always open against the default branch.
Security updates are all this repository gets, since there is no dependabot.yml at
all, so every one of them arrives on main.

The branch check then labelled them wrong-base-branch and close-stale-wrong-branch
shut them 24 hours later. Thirteen of the seventeen Dependabot pull requests died
that way; the four that survived were retargeted by hand. The comment the check
leaves asks the author to click Edit, which a bot cannot do.

So retarget them instead. If that fails, because the branch was cut from main and
dev has moved on, it falls through to the existing path so a human sees it rather
than the check quietly passing.

Note this only takes effect once it is on main: pull_request_target loads the
workflow from the pull request's base branch, and these arrive on main.
2026-08-13 15:39:35 +02:00
Maurice 9dd0a851ca fix: eleven reported bugs across atlas, budget, collections, journey, vacay and the LLM settings (#1923)
* fix(mcp): accept per-leg times on create_transport and update_transport

metadata was pinned to Record<string, string>, so a legs array could not get
through and a multi-leg flight built over MCP lost every intermediate time. The
booking ended up with correct endpoints and no legs, which every reader then
treats as a single segment.

legs is now a real, typed parameter with its contract in @trek/shared, shaped
exactly the way the planner form and the importers write metadata.legs. The
folding checks what the readers depend on: one leg fewer than endpoints (the
AirTrail dedupe builds its signatures on that), day ids that belong to the trip,
airport codes that agree with their endpoint, and the times, where a stopover
endpoint carries the departure and the last one the arrival.

metadata keeps its type and its replace semantics. Segments smuggled through
metadata.legs as a JSON string used to be stored and then ignored by everything
that read them; they are refused now and point at the legs parameter instead.

Closes #1914

* fix(atlas): refuse a bucket list entry that is already on the list

Nothing gave a bucket entry an identity, so the same country could be added as
often as it was clicked. A wish is now identified by name, country, target date
and, where it has them, coordinates. A different target date is a different wish
and still lands, which is what the report asked for; two real places sharing a
name stay separate because their coordinates differ.

Empty string and NULL are normalised on both sides of the comparison, otherwise
the dateless case, which is the one that was reported, would slip through.

Deliberately no unique index and no migration. better-sqlite3 is synchronous and
the server is a single process, so the race an index would guard against cannot
happen here, and an index would have needed a destructive dedupe of existing
rows first.

create_bucket_list_item had no target_date parameter at all, so over MCP the
allowed "same place, other date" case was unreachable. It takes one now. The
plugin RPC reports the duplicate as bad params instead of letting it surface as
an internal error.

Closes #1898

* fix(settings): keep a free-form LLM endpoint in admin hands

Any account could name the address the server calls, and the guard in front of
it deliberately allows loopback and LAN so a self-hosted Ollama works. That is a
reasonable trade for whoever runs the instance, because the network is theirs. It
is not one for an arbitrary account, which could aim a request from inside that
network at a target only it chose and read how the target answered.

A free base URL, and the local provider that is one by another name, now come
only from an admin controlled source: the addon config, the instance-wide
defaults, or an admin's own settings. OpenAI and Anthropic stay available to
everyone, because they go to a fixed address with the user's own key.

The resolver is where this is enforced, since it is the single point both the
booking import and the plugin host pass through, and the only one that also
covers values already sitting in the database. The write path refuses as well,
so nothing is stored that would never be read.

The hint text promised something users no longer have, so it changed with it.

Closes #1772

* fix(calendar): span a date-only booking to its end date

A DATE event without DTEND is exactly one day per RFC 5545, so a multi-day
booking that carries an end date still collapsed onto its first day. It gets an
exclusive DTEND now, built the same way the stay branch already builds one. A
booking without an end date serialises byte for byte as before.

While in here: the stays query joins reservations on the accommodation, and two
bookings pointed at the same stay fanned it out into two events sharing one UID.
Duplicate UIDs in a feed are undefined behaviour and calendar clients pick one at
random.

The same "at most one booking per stay" assumption sat in the accommodations
service, where it was worse than cosmetic: accommodation_id carries no foreign
key, so deleting a stay took only the first booking and left the second pointing
at something that no longer existed. Both the sync and the delete walk all of
them now. The callers keep reading linkedReservationId; the list is additional.

Refs #1869

* fix(vacay): say that the region is required, because it is

The placeholder called the region optional while the add button stayed disabled
until one was picked. The wording predates multiple calendars per plan, which is
what introduced the gate. Only the value of the existing key changed, in all
locales, so the key set stays identical.

Two things the same screen got wrong were in the way of a clean answer:

The button was clickable while the region list was still loading, which put the
user in exactly the state the wording describes, and it is the state the report
was filed from. It waits for the list now.

Changing the country on an existing calendar kept the previous country's regions
on screen, so a German region could be picked for the Netherlands. The list is
cleared with the country, which only works in combination with the loading gate:
without it, the empty list would happen to enable the button instead.

Closes #1813

* fix(collections): let a saved place's address be corrected

The edit form offered name, category and labels, and the address only on the way
in. The cause was not the form: the update contract never declared address, so
the validation pipe stripped it and the UPDATE had no column for it. Coordinates
were pulled through for #1435 and the address was missed.

It travels the whole chain now, contract to service to both forms. No migration,
the column has always been there.

The mobile add sheet had no address input at all, showing whatever the search
result carried as read-only, so editing would have worked while creating still
would not. It has one.

The MCP tool description had drifted further than the address, never having
mentioned the coordinates either. Both are in it now.

Closes #1870

* fix(collections): keep the place search usable after the first save

The add dialog stays open on purpose so several places can be added in a row, but
after a save it gave no way back into the search. A search with no results was
silent, with no empty state and no toast, so typing another name and pressing
enter looked like nothing happened. The reporter described both halves and it is
the silent search that matches "nothing happens" most closely.

There is an empty state now, using a key that already existed in every locale and
was unused. The search row is sticky inside the dialog body, so it stays reachable
after the save instead of sitting above the scroll position.

Also fixed while in here: refreshing the collection after each add reset the
select mode, the selection and the label filter. In the very flow this dialog is
built for, the user quietly lost all three.

Closes #1921

* fix(vacay): make the phone year grid and the dock button do what they look like

Two separate faults met on this screen.

Tapping a day in the year grid did nothing unless the edit mode was already on,
and the only way into it was a floating button that looks exactly like the one
the dock draws in the same spot. A tap now opens that month in the editor, where
the cells are big enough to hit. At 390px the grid cells are around 21px, which
is too small to be mutating data with in the first place.

The dock's plus was not wired for this route, so it fell through to creating a
trip on the dashboard. Two different actions were stacked in one place, and the
wrong one was on top whenever the screen had not painted yet, which includes the
time its chunk is still loading. The dock leaves the slot to the screen now, and
holds the geometry so the tab groups stay symmetric.

Closes #1811

* fix(budget): settle in whole cents so balances stop drifting

The report is a balance that moves on its own, with nothing added, edited or
removed, on a trip where everything is in euro. Two mechanisms were behind it and
both are real.

The display currency is a fourth value, a per-user setting, so "everything is in
euro" never covered it. Each balance was converted and rounded on its own, and
the set no longer summed to zero: at 0.855 and 0.9312 it came out one cent over,
at 0.94 it came out even. That cent appears and disappears with the six hour rate
cache alone, which is exactly the symptom. The flows were rounded separately from
the balances, so the two never had to agree.

And a receipt line nobody was ticked for counted toward the total, and therefore
toward the payer's credit, while landing in no share. That difference is
permanent, and the client sent it that way.

The settlement converts once, at the edge, and does integer arithmetic after
that. Equal splits distribute the credited cents by largest remainder rather than
the entered total, so credits and debits of one expense cancel exactly even in a
foreign currency. Display conversion goes through the same largest-remainder
allocation, so the sum survives it.

With that, the 0.01 epsilon can go. It could never create a cent, but it stranded
them: a one cent balance was filtered out of the flows while staying visible in
the list, which is the "settle up offers nothing" half of the report.

Closes #1382

* fix(journey): reach the book export and the suggestion toggle on a phone

Below 1024px the hero card is dropped, and it held the only way to either action.
The floating bar that replaces it was never given them. It has them now, and the
hero is unmounted rather than hidden, so there is no second invisible copy of the
same controls in the document. The bar's own buttons moved onto the appearance
tokens while they were being touched.

The phone screen never had either action at all and now carries both.

The book itself was hardwired to English, cover and dates included, while the
keys for it had been sitting in every locale unused. It takes the page's
translator and locale, sets the document language, and prints mood and weather,
which were on screen but never in the book.

Closes #1848

* fix(journey): offer the connected photo providers in the phone entry editor

The desktop editor grew an external photo source in #1632; the phone editor that
came out of the mobile rewrite never did, so editing an entry on a phone had no
way to Immich or Synology. It mirrors the desktop mechanics, including the part
that is easy to miss: a save that creates the entry and then fails on the photos
keeps the sheet open, and without remembering what was just created the retry
creates a second entry.

The sheet no longer kicks off its own addon load. The list is loaded once on
boot and this only reacts to it; a second load races the other consumers and
overwrites the store when it lands.

Closes #1808

* fix(mobile): let the document scroll so Safari can collapse its URL bar

iOS Safari only shrinks its address bar when the document scrolls, and TREK's
never did: the root was locked and the mobile shell put a scroll container inside
it. On the bottom tab layout that costs about a sixth of the viewport,
permanently.

Below 768px the document is the scroller again and the full-bleed screens measure
their own height instead of leaning on the shell.

That makes the body scroll locks real for the first time on a phone, and they
were not ready for it. Three of them reset the overflow unconditionally, one of
those on every run of its effect rather than on unmount, so a system notice could
hand the page back while a sheet was still open. They all go through one
reference counted lock now, which is the part that had to land first.

Worth knowing for the issue: this reaches the flow screens. The trip planner, the
journal, the atlas and the vacay screen keep their own full-height scrollers and
still will not collapse the bar.

Closes #1809

* fix(mobile): keep the screen gradient visible behind the translucent cards

The gradient layer introduced with the scroll change sat at z-index -10. A
negative layer paints above the canvas but below the backgrounds of ordinary
blocks, and body carries an opaque one, so the gradient was hidden behind it.
The mobile cards are translucent and take their depth from what is behind them,
so every surface ended up compositing against a single flat colour and the whole
phone UI read as one tone.

The layer is positioned at z-index 0 now and the content above it at 10, which
puts both over body's background. Same visual as before the scroll change, and
the shell still owns no scroll container.

* test(reservations): teach the accommodation delete stub about the id lists

The RPC walks every linked booking since the delete stopped stopping at the
first one. The stub still answered with the two singular fields, so the loop had
nothing to iterate and the cascade events never fired.

* fix(settings): move the free-form LLM endpoint out of user settings entirely

Hiding it from non-admins closed the hole, but it left the option sitting in the
admin's own personal settings, which is not what was asked for and not where it
belongs: an instance has exactly one address it calls out to, and there was a
second, invisible place to set it.

The provider list is now the two hosted ones for everybody, the endpoint field is
gone, and the resolver takes the address from the admin-controlled instance
defaults for every caller including an admin. It no longer reads the users table
at all. The write path refuses the keys for everyone, so nothing can be stored
that would never be read.

The endpoint is configured on the addon in the admin settings, which already
carries provider, model, base URL and key, plus the Ollama model list. Nothing
is lost, it just has one home now.

Behaviour change worth a release note: an admin who set a personal Ollama
endpoint has to move it to the addon config. A non-admin was already in that
position after the previous commit.

Refs #1772

* fix(journey): make the book preview usable on a phone

The preview document carried no viewport, so a phone laid it out at its default
980px while the page is A4 landscape and about 1123px wide. It spilled out of
the frame, which reads as being zoomed far in. Declaring the real width lets the
browser scale the page to fit. Printing is unaffected, it goes by @page.

The preview chrome had the same problem in miniature: a title and two labels on
one row, sized inline, which does not fit a phone and fits translated labels
even less. It moved to a stylesheet, and below 640px the card goes full-bleed,
the decorative title steps aside and the two actions share the width.

Refs #1848

* fix(mobile): stop the shell trapping the screens' own overlays

Lifting the background layer into the positioned range meant the content wrapper
had to be positioned too, and a positioned wrapper with a z-index is a stacking
context. Every fixed overlay a screen renders inside it got clamped to that
level: the vacay view/edit FAB sits at z-50 precisely so it beats the dock at
z-40, and it disappeared behind it.

The layer goes back to a negative z-index and the wrapper back to no positioning
at all. What it needed in the first place was for body to stop painting over it,
which is one line in the phone media block rather than a new stacking context.

* fix(mobile): let the phone background override actually win

A media query adds no specificity, so the themed body rule further down the file
kept winning on source order and painted over the shell's background layer. The
override is on html body now.

The gradient was back to being invisible, which is the flat single-tone phone UI
again.

* fix(vacay): draw today's ring around the day instead of replacing it

On the phone, today short-circuited the day-cell matrix and returned a ring on an
empty cell. A vacation day or a company holiday logged for today was stored and
counted in the totals, but the cell showed nothing, so it read as if the tap had
not taken.

Today is an annotation now, layered like the school band and the shared-calendar
rings, which is how the desktop month card has always treated it. The strong ink
stays only where the cell has no fill of its own, since --m-ink is light in dark
mode and would disappear on a pastel.

* fix(map): stop serving a stale GL style document from the service worker

The Workbox rule for Mapbox matched every URL under api.mapbox.com, and the
style document lives there too. Under StaleWhileRevalidate the map was built
from the previous revision on every load, with the refresh landing one load too
late, so a style republished in Studio with different label languages kept
rendering the old ones. The network tab showed a 200 throughout, because the
service worker's own answer is a real 200, which is why this looked like the app
rewriting text-field at runtime.

The style now has its own rule ahead of the tile rules, on NetworkFirst so it is
current whenever the network is, and still cached so the map opens offline. Its
own small cache too: sharing an LRU with three thousand tiles made the staleness
come and go at random. OpenFreeMap styles had the same problem and are covered
by the same rule.

Closes #1924
2026-08-13 14:06:35 +02:00
Maurice 3c5e406229 feat(places): link an expense to a place, the way bookings already do (#1917)
* feat(places): link an expense to a place, the way bookings already do

Recording what a museum ticket or a guided tour cost meant leaving the place,
opening the Costs tab and typing it in again — while a booking has had a Costs
block in its own form all along.

The place form now carries that block. Same component, same flow: with nothing
linked it offers Create expense, which saves the place and then opens the Costs
editor for a new expense pointing at it; once linked it shows the expense with
edit and remove. budget_items gains place_id beside the reservation_id it
already had, with the same nullable FK and the same ON DELETE SET NULL. Desktop
and phone both, and the mobile editor carries the link through its own save path
rather than the desktop one.

The expense belongs to the place, not to a day assignment, so a place sitting on
five days is not five tickets. Hanging it off the assignment would have meant
adding a place to three days quietly creating three expenses and removing a day
quietly deleting one.

Deleting a place takes its expense with it in the same transaction and announces
it, on every path that deletes a place: REST, both MCP tools and the plugin RPC.

Closes #1298

* test(places): teach the RPC stubs about linkedExpenseIds

The delete paths read the linked expense before the place is gone, so the two
hand-built PlacesService stubs threw on the new method instead of reaching the
hook, the remove and the broadcast — four failures out of one missing line.
2026-08-12 21:37:12 +02:00
Konstantinos Thermos e59948e707 Merge pull request #1913 from liketrek/fix/atlas-bucket-search-dropdown-clipped
fix(atlas): stop the panel clipping the top of the bucket search results
2026-08-12 18:53:35 +03:00
Maurice df3edac7b9 feat: six community requests — import dedup, accommodations in the feed, OAuth refresh race, visited-from-trip, journal jumps, phone packing rows (#1915)
* fix(places): match imports on the provider id so renamed places are not duplicated

Re-importing a Google Maps list duplicated every place the user had renamed.
buildDedupSet only ever collected names and, for unnamed places, coordinates,
and isPlaceDuplicate returns on the name before it reaches the coordinate
fallback, so a renamed place was findable by neither. The ids were stored all
along, and even backfilled onto matched places, they were simply never read.

Match on google_place_id / google_ftid / osm_id first, keeping name and
coordinates in their old roles below it. Widening the coordinate check instead
would have merged the restaurant and the bar in the same building.

The collection copy carried a hand-mirrored port of the same predicate; it now
uses the shared helpers and gets the fix with them.

Closes #1550

* feat(calendar): put accommodations into the subscribed feed as all-day stays

A hotel keeps its dates on the linked stay, not on the reservation: the booking
form writes reservation_time = NULL for type 'hotel', and day_accommodations
carries start day, end day and the check-in/out clock. The feed only ever read
the reservation, so a stay showed up once on the arrival day at best, and not at
all when it came from the booking form.

Join the stay in and emit the reservation as an all-day range over every night,
which also keeps it correct when days are reordered later — the dates now come
from the day rows rather than from the copy stamped at creation time. Check-in
and check-out become their own timed events when the stay records a clock,
because an all-day event cannot carry the time you actually need reminding of.
The check-in window, when set, becomes that event's DTEND.

Closes #1586

* fix(oauth): treat a concurrent refresh as a race, not as a stolen token

Refresh rotation is a race by construction. Several MCP clients share one
refresh token, both post it within the same second, and the loser was read as a
replay: chain revoked, MCP sessions torn down, browser window asking to log in
again. That is the daily re-authentication people were seeing, not a short
lifetime — access tokens last an hour and refresh tokens roll for thirty days.

A revoked refresh token now only counts as a replay once it is outside a
30-second grace window, and only when the rotation that revoked it left a
successor still alive. Inside the window the caller gets its own pair branched
off the same parent, so both clients walk away with a working token. Explicit
revocation leaves no live successor and a chain killed by a real replay has none
either, so neither can slip through the window.

Closes #1007

* feat(collections): mark a place visited from the trip it belongs to

A place saved in several lists had to be opened in each of them to say you had
been there, which is the wrong way round: you find out you have been somewhere
while looking at the trip, not while browsing the library.

The save picker now shows each list's own status and cycles it in place, with one
action for "visited, wherever this is saved". The planner's selection bar and the
mobile bulk toolbar carry the same thing for a whole selection — select all, one
tap, done. Matching happens on the server with the signals findMembership already
uses plus the source link a place saved from this trip carries, so a copy renamed
inside a list is still found. Lists you may only read are skipped rather than
refused; a shared list you cannot edit should not stop you marking your own.

Closes #1469

* feat(journey): jump to the top or back to the last entry in a long journal

Everything that adds to a journal sits at the top — the entry button, the
gallery upload — and where you left off sits at the bottom, so adding one photo
to a long journey meant scrolling the whole way twice.

Two buttons float over the middle of the feed, clear of the Add Entry column on
one side and the reorder arrows on the other, each appearing only when there is
more than a screenful to travel in that direction. They live in a zero-height
sticky box so they take no layout space and never push the timeline around.
While in the header, the status pill also picks up the height of the round
buttons beside it.

Closes #1088

* fix(packing): give the item name the row back on phones

An item name had roughly a third of the row to itself: a spelled-out sharing
sentence, an empty weight column and two round action buttons took the rest, so
anything longer than a word was cut off.

Sharing is now the icon it always was next to a label rather than a sentence in
the layout, an unset weight prints nothing instead of a dash, and edit/delete
sit behind one overflow button that opens the same inline menu the bag picker
already uses. The to-do list needed none of this — it wraps its chips onto a
second line already.

Closes #1525

* docs(wiki): cover the six community changes

- Calendar feeds: accommodations as an all-day range plus check-in/check-out
- Collections: setting the visited status from a trip, and how places are matched
- Journey: the jump buttons on a long journal
- Packing: what the phone row keeps and what moved behind the overflow button
- Places: re-importing a list no longer duplicates renamed places
- MCP setup: the rotation race no longer ends an OAuth session

* test(collections): assert the stored status instead of a private method

tests/ sits outside the build tsconfig, so reaching into getPlaceById only
showed up in the dedicated typecheck step. Reading the row is the better
assertion anyway — it pins what was persisted, not what a getter returns.
2026-08-12 17:43:22 +02:00
Konstantinos Thermos 85e820dee5 fix(atlas): stop the panel clipping the top of the bucket search results
The bucket-list place search opened its result list upward with
position:absolute inside the desktop Atlas panel, which is overflow-hidden
for its rounded corners. The panel leaves ~125px above the input, so the
top of the 160px list was painted outside the clip box — the first result
was invisible and unreachable, since that is the container's own top and
not a scroll offset.

Portal the list to the body and position it fixed off the input row,
flipping up or down by available room and capping its height to that room.
This matches CustomSelect, whose month/year dropdowns already escape the
same panel this way.

Fixes #1899
2026-08-12 17:31:35 +03:00
Konstantinos Thermos 813b9886b3 fix(vacay): let a day stranded by a weekend-config change be cleared (#1912)
Clicking a logged day that a weekend-days change had turned into a weekend
day was swallowed by the client, while the entry kept counting against the
entitlement. The server already allows the removal (only conversions and new
inserts are refused on a blocked day), so mirror that: a blocked weekend cell
holding the acting person's entry stays clickable and clears it with the
entry's own fraction/kind. Logging a new weekend day stays blocked.

Fixes #1897
2026-08-12 16:22:04 +02:00
Maurice 870bd6131f feat(journey): draw the trips' GPX tracks on the journey map (#1911)
* feat(journey): serve the GPX tracks behind a journey

A journey has no trip of its own, but every entry records the trip it came from,
and a trip's routed geometries live on its places. So the tracks are derivable
without any new storage: GET /journeys/:id/tracks walks that link and returns the
geometries, dropping elevation nothing on the map reads and skipping anything
that is not a usable line.

* feat(journey): draw those tracks on the journey map

Both renderers, Leaflet and GL, plus the mobile map-timeline that shares them.
Solid and in the track's own colour with a white casing, so it reads as a
recorded route rather than as the dashed line that merely connects entries in
time order, and stays legible on satellite tiles.

* feat(journey): load the tracks alongside the journey

Fetched separately from the journey itself: most journeys have no tracks, and a
failed lookup should cost the map its lines rather than the whole page.

* docs(journey): document the GPX tracks on the map

* fix(journey): label tracks with the house map tooltip

bindTooltip's default box ignores the appearance tokens, so it sat there in
Leaflet grey next to markers already using .map-tooltip. Same class now, and an
unnamed track gets no tooltip at all instead of an empty one.
2026-08-12 15:14:50 +02:00
Maurice 767dc662b7 feat(atlas): search by place, and count cities that are actually cities (#1908)
* refactor(atlas): drop the last-trip tile from the stats bar

The row beside the continent counts is statistics; a single trip sitting in it
was neither, and the trip it named is already one click away in the country
detail below. Its two locale keys go with it, along with atlas.lastVisitLabel,
which nothing had rendered for a while.

* feat(atlas): resolve a coordinate to its country and region

Groundwork for searching the atlas by place: /locate answers with the admin1
region a point falls in, resolved against the same bundled polygons the visited
colouring uses, so the answer is always something the map can highlight. A
country without admin1 coverage resolves to a country and a null region, which
is a real answer rather than a failure.

* feat(atlas): search the map by place, not only by country

Typing a city geocodes it through the same /maps/search everything else uses and
offers the hits below the instant local country matches. Picking one flies there
and opens the dialog for the region it sits in, so Milan marks Lombardy without
the user knowing that is where Milan is.

The country list stays offline and instant; only the place lookup waits on the
network, debounced and sequence-guarded so a slow answer cannot overwrite a
newer query.

* fix(atlas): count cities instead of shop names

The visited-cities figure scanned the address left to right and took the first
part that was not a country and matched a latin-only pattern. The first part of
a geocoded address is the place itself, so a trip to Tokyo counted Shibuya Sky,
Shibuya Parco and Family Mart as three cities, while an address written in
Japanese, Cyrillic, Greek or Korean counted none at all.

Read from the administrative end instead, skipping the country, anything with
digits, and the region already resolved for that place in place_regions, which
is what separates the city from the region above it. On one real trip the count
went from 20 entries, nearly all of them streets and shops, to Paris, Shibuya,
Minato and three Berlin boroughs.

* i18n(atlas): translate the place-search heading

* docs(atlas): document place search and how cities are counted

* fix(atlas): stop the search panel closing itself while results arrive

The dropdown closed on mouseleave, which was survivable while it held only local
country matches and appeared complete in one go. Geocoded places land a moment
later and grow the list under a cursor already resting there, so the panel shut
itself seconds after opening. Closes on a click outside now, Escape unchanged.
2026-08-12 14:14:37 +02:00
Maurice 6e10ffa75d test(mobile): follow the export sheet's rename (#1909)
Two suites under client/tests/ still looked for "Export Calendar" by accessible
name. They live outside src/, which is why the targeted runs while building this
missed them and only CI caught it.
2026-08-12 14:02:39 +02:00
Maurice 9ce6eed9ad feat(places): export a trip as GPX (#1907)
* feat(places): hand a trip back as GPX

TREK reads GPX but never wrote it, so a planned trip could not reach the offline
map or the handheld it was planned for. The importer already fixes the mapping:
a wpt is a place, a rte or trk is a place with route_geometry, so writing is that
reversed. Days have no import counterpart and become routes of their stops in
order, which is the part that puts a plan on a device.

XMLBuilder comes from the same fast-xml-parser the importer parses with, so
nothing new is pulled in.

* feat(dayplan): a GPX button beside PDF and ICS

Scopes are worded by what someone wants on their device rather than by the GPX
element: the whole trip, just the places for an offline map, just the days to
follow a plan. An empty trip says so instead of reporting a failed download.

* feat(mobile): GPX in the export sheet, which is no longer calendar-only

The sheet held the PDF export next to the calendar entries and was still called
Export Calendar; with GPX beside them the name was simply wrong.

* i18n(dayplan): translate the GPX export into all locales

* docs(map): document the GPX export

* fix(dayplan): drop the GPX tooltip and centre both export menus

The tooltip sat under the cursor exactly where the menu opens, so hovering the
button produced both at once. The label lives on as an aria-label. Both menus
now open centred under their button instead of hanging off its right edge.
2026-08-12 12:45:29 +02:00
Maurice b842d74a5d feat(collections): import a whole trip's places into a list (#1903)
* feat(collections): preview which of a trip's places a list already holds

The bulk import endpoint has been there since the select-mode copy, but nothing
calls it and it only reports duplicates after the fact. A dialog needs the verdict
up front, so /:id/importable/:tripId returns the trip's places with the duplicate
check the import itself applies, plus whether any day holds the place.

* feat(collections): import a whole trip's places into a list

Two steps: pick a trip, then tick its places. Places already on the list are
greyed out and unpickable, and the ones no day holds start selected — those are
the plans a trip left behind, which is the reason to import at all. Sorted so a
mostly-saved trip reads as the two that are left rather than a wall of grey.

* feat(collections): reach the trip import from the filter row and an empty list

* i18n(collections): translate the trip import into all locales

* fix(collections): stop the label dialog's buttons wrapping mid-word

Three actions in a max-w-sm footer wrapped into two lines each. The assign
button also painted white text on the accent, which disappears on a light one.

* docs(collections): document the trip import
2026-08-12 11:54:36 +02:00
Mauro Remondi b0fd73681b feat(atlas): show wishlist countries as hatched, stable colors, remove action (#1893)
* feat(atlas): show wishlist countries as hatched, stable colors, remove action

* i18n(atlas): translate the wishlist removal keys into all locales

* feat(atlas): offer the wishlist removal in the mobile country sheet

* docs(atlas): document the wishlist hatch and the removal action

* test(atlas): assert the stable country colour instead of a palette literal

---------

Co-authored-by: Mauro Remondi <mauro.remondi@sourcesense.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-08-12 09:49:38 +02:00
Maurice 27ffd3ec68 fix(db): append the incoming leg mode migration so upgrades pick it up 2026-08-12 09:08:47 +02:00
Ida Rogalska 15c8675959 client: omit default outgoing direction from assignment transport payload
The per-segment transport request now carries a direction, but the server
schema defaults it to 'outgoing'. Only send direction for the incoming
boundary-leg case so the outgoing payload stays identical to the pre-#1281
shape and existing callers keep the same wire format.
2026-08-12 09:08:47 +02:00
Ida Rogalska e5c885e768 client: target the real bookend endpoint from hotel/booking mode menus 2026-08-12 09:08:47 +02:00
Ida Rogalska f5e06e0dea test: outgoing and incoming leg modes persist independently 2026-08-12 09:08:47 +02:00
Ida Rogalska 8c3a235c3b client: editable travel mode on booking and hotel-bookend connectors 2026-08-12 09:08:47 +02:00
Ida Rogalska 38bdb1d4b7 client: resolve boundary-leg modes from either endpoint in the map route calc 2026-08-12 09:08:47 +02:00
Ida Rogalska 5679ea76d2 client: resolve boundary-leg modes from either endpoint in the day sidebar 2026-08-12 09:08:47 +02:00
Ida Rogalska 899316d5f8 client: add resolveLegMode helper for endpoint-aware leg modes 2026-08-12 09:08:47 +02:00
Ida Rogalska cedbe39b35 api: PUT /assignments/:id/transport gains direction (outgoing|incoming) 2026-08-12 09:08:47 +02:00
Ida Rogalska edb21b681f read: project incoming_leg_transport_mode via rowShape + getAssignmentWithPlace 2026-08-12 09:08:47 +02:00
Ida Rogalska bb6b946868 types: incoming_leg_transport_mode on assignment schema + rows 2026-08-12 09:08:47 +02:00
Ida Rogalska a17bfdb1a1 db: add incoming_leg_transport_mode to day_assignments 2026-08-12 09:08:47 +02:00
jubnl ac42cc941b Merge remote-tracking branch 'origin/dev' into dev 2026-08-12 00:39:00 +02:00
jubnl 7e7277beb0 fix(vacay): enforce weekend blocking in toggleEntry service
With block_weekends on, toggling a weekend day via MCP or the plugin RPC
still added the entry and burned allowance — the guard lived only in the
web UI (mcp-smoke-test I-02). toggleEntry now rejects inserts and
in-place conversions on blocked days with { error: 'weekend_blocked' }
(the votePoll pattern); removing an existing entry stays allowed so
stray weekend entries remain cleanable. The controller maps the code to
a 400, the MCP tool to an isError result.
2026-08-12 00:38:41 +02:00
jubnl 705c3c08dd fix(trips): remap cross-links and copy split rows when duplicating a trip
Copying a trip nulled budget->reservation and reservation->accommodation
links (TEXT accommodation_id vs number-keyed map) and dropped
budget_item_members/payers, assignment_participants, budget currency/
exchange_rate/expense_date/ticket_json and reservation url. All links now
remap to the copied rows; external_* sync identity is intentionally not
copied; the to-do reset is now documented (smoke-test I-01).
2026-08-12 00:22:32 +02:00
Maurice 62bc7ff8fd Jump to today, map-app links, drag to day, PDF landing time (#1891)
* Open a running trip on today

Asked for in discussion #1567: during a trip, opening the planner starts
at day 1 and you scroll to find out what is planned for today.

The mobile shell has done this since the plan tab was built - it seeds
the active day with today when the trip is running. The desktop day plan
never did, so the same trip behaved differently depending on the screen
you opened it on. It now selects today's day, expands it and scrolls it
into view, once per mount and only while nothing is selected yet, so a
deep link or a day picked before switching tabs still wins.

The date comparison moved into one helper both sides use. It is
deliberately local rather than toISOString(): 08:00 in Tokyo is still
yesterday in UTC, and a trip day is a calendar day where the traveller
is standing.

Also fixes a note losing its colour when dragged to another day. A move
is a delete plus a create, and the create was still sending the three
fields notes had before colours existed.

* Open a collection place in a map app

Asked for in discussion #1455: a saved place is somewhere you intend to
go, but the only way to get directions to one was to add it to a trip
first, where the button has been for a while.

The collection detail gets the same picker off the same helper: Google
Maps, Waze, Apple Maps and OpenStreetMap, filtered by what the place can
actually be located with. A place with coordinates offers all of them; a
place typed in by hand with just a name and an address still offers the
two that take a search string, and a place with neither shows no button
at all.

It sits on the meta line beside the address, quieter than the link chips
below it, because it is an action on the place rather than a piece of it.

* Drag a place off the map onto a day

Asked for in discussion #891: assigning a place means finding it again in
the sidebar, even though it is right there under the cursor on the map.

The drop half already existed and did not need touching. Every day, every
row and every gap between rows in the day plan reads a placeId and calls
onAssignToDay with the position it was dropped at, complete with the
toast and the undo entry. What was missing was a second place to pick a
place UP from - the only one was the row in the places sidebar.

Markers become that second source. Both renderers build their markers as
real DOM nodes, so one helper covers Leaflet and the GL variants alike,
and it sets the same two channels the sidebar row does: dataTransfer for
the drop, and window.__dragData because browsers hide dataTransfer while
the pointer is merely hovering. The day plan cannot tell the two apart.

Not offered on touch: HTML5 drag is a pointer feature, and on a phone
the day plan is not on screen next to the map anyway.

* Show the landing time in the PDF

Asked for in discussion #1310: the export prints a flight's departure and
nothing else, so a reader cannot tell how much of the day is left for
anything after it.

The day plan has drawn both times as "14:30 - 16:45" for a while; the
export never got the rule. Now it does, for any transport that begins and
ends on the same day - a train and a ferry read the same way a flight
does.

Across a span it stays as it was: the arrival belongs to the arrival day,
which already prints it as its own time, and repeating it on the
departure day would put tomorrow's clock next to today's departure.
2026-08-11 23:26:58 +02:00
jubnl 92b9e7548e Merge remote-tracking branch 'origin/dev' into dev 2026-08-11 23:26:36 +02:00
jubnl 04812702ec docs: close the bridge chapter 2026-08-11 23:25:22 +02:00
jubnl a01476f445 feat(mcp): audit MCP tool calls at the registry seam 2026-08-11 23:19:39 +02:00
jubnl 1239be4259 refactor(server): fold the _shared MCP helpers into an injectable guards provider; delete the last bridge 2026-08-11 23:11:34 +02:00
jubnl 954a3ca0a7 refactor(server): serve /mcp from a Nest controller; delete auth, audit, addons and oauth bridges 2026-08-11 23:01:19 +02:00
jubnl fac442086f refactor(server): providerize the OAuth SDK adapter behind the container 2026-08-11 22:44:20 +02:00
jubnl 1390bfbe06 refactor(server): move health, discovery and consent-COOP behind the container 2026-08-11 22:23:51 +02:00
Maurice ce37e8e97c Rich formatting and colours for notes (#1889)
* Rich formatting and colours for notes

Asked for in discussion #1629: notes should carry formatting, links and
colour, so a warning reads differently from a recommendation.

Notes have rendered Markdown for a long time. The problem was that the
only way to write it was a bare textarea, so unless you already knew the
syntax there was nothing to tell you it existed. Both note fields get a
formatting bar - bold, italic, strikethrough, code, link, lists, quote -
which edits Markdown rather than replacing it with a rich-text model, so
the stored value stays plain text that the PDF, the plugin API and every
existing note keep reading unchanged.

Links now open in their own tab. A bare anchor navigates the planner
away, which loses the open day and the map, and without rel=noopener the
opened page can navigate this one - notes are content other trip members
wrote.

Day notes get a colour from a short shared palette. It tints the card,
the border and the icon circle, and it reaches the PDF as the spine and
rule rather than a fill. The dialog is two columns now, with a live
preview of the card beside the picker, because a row of swatches says
nothing about what a tinted note looks like next to a place.

On the phone the thirty-two icon grid was most of the screen, so it is
folded behind the chosen icon, which doubles as the colour preview. The
detail field there got the same formatting bar and the same 2000
character ceiling as the desktop one - they write the same column.

* Fix the tests that pinned the old note caps, and rework the note actions

The e2e suite builds its own day_notes table by hand rather than going
through schema.ts, so it never got the new column and every note insert
came back 500. Three more tests wrote down the 250 character body cap
and the old create() arity.

While in there: the edit and delete buttons on a note card were a 10px
icon in a 14px box, side by side, and they took width away from the text
on every note whether you were hovering or not. They are stacked in a
capsule now, 30px targets with their own hover states, floating over the
right edge of the card only while the pointer is on it. The text keeps
the full width the rest of the time.

* Centre the day badge when there is no weather to stack under it

The badge is pinned to the top of the row because with weather it is a
tall stack that has to start there. Without a location there is no
weather block, so all that is left is a 26px circle floating above the
day title.

* Render the place subtitle in the places list, and drop the form hint

The row in the places sidebar printed the description raw while the same
line in the day plan renders it, so a place written with the formatting
bar read as _underscores_ in one list and italics in the other. Still
clamped to one line - it is a list entry, not the inspector.

The "Markdown supported" line under the notes field goes: the bar above
it already says so.
2026-08-11 22:14:12 +02:00
jubnl a5202f418b Merge remote-tracking branch 'origin/dev' into dev 2026-08-11 21:15:40 +02:00
jubnl f842073351 refactor(server): delete airtrail.bridge and notifications.instance via a core/pull split 2026-08-11 21:13:32 +02:00
jubnl c1819c7ab5 refactor(server): delete assignments.bridge via an assignments-domain module split 2026-08-11 20:58:37 +02:00
jubnl 079064fdcd refactor(server): delete trips.bridge via leaf membership reads and prompt relocation 2026-08-11 20:28:49 +02:00
Maurice 435b782c85 Add a note field to expenses (#1888)
Requested in discussion #1658: an expense carries an amount and a
category, and everything else about it ends up crammed into the title.

The column was already there. It just was not available, because the
costs UI stores an itemized receipt in it as TICKETJSON:{...}, so an
expense split by receipt could never carry a note - and saving one any
other way wrote note=null, which quietly wiped whatever had been typed
into the budget table. The receipt gets its own column and note goes
back to being text. Old clients that still send the blob as a note are
routed to the right column instead of clobbering the note.

On desktop the row now has fixed columns, so the amount does not drift
left when one row has a note and the next does not. The category moved
to the card edge as a tab like the mobile card does, which is what frees
the line the note sits on. Collapsed it is one line, and the same
element grows in place when opened.

The dialog is two columns now: what the expense was on the left, how it
is shared on the right, grouped into panels the way the trip dialog
does it.

On the phone the eleven category pills became a dropdown, which is where
the room for the note field came from, and a note on a card unfolds when
you tap it.
2026-08-11 18:13:07 +02:00
Maurice a39c31cb48 Hide permanently closed places from search (#1887)
A place that has shut down for good is never the answer to "where should
we go", so it should not be in the results at all.

Google knows this and says so in businessStatus, we just never asked for
the field. It goes into the field mask and CLOSED_PERMANENTLY drops out.
CLOSED_TEMPORARILY stays - a restaurant on holiday next month is still
worth planning around - and results without the field are parks and
viewpoints rather than businesses, so they stay too.

OSM states it a few different ways. Re-tagged objects (disused:amenity,
abandoned:shop) never matched the selectors to begin with, but plenty
keep their original tag and gain a marker instead, and those did come
back. Same for opening_hours=closed/off.
2026-08-11 17:04:09 +02:00
jubnl e1162c1c07 docs: bring the bridge docs in line
Roadmap census 14 -> 8 (5 seam-pinned, 3 verified-permanent), resources.ts DoD line closed, README in-container list and counts corrected. Includes the SystemNoticesService unit test the src/nest coverage ratchet demanded after the addonEnabled threading.
2026-08-11 17:00:05 +02:00
Maurice 6fe89588c2 feat(plugins): give plugins the surface, insets and mobile palette they need (#1886)
* Tell a plugin where it is mounted

A plugin sits in one of six host surfaces and cannot see which: a trip tab and
the plugin page fill their container and scroll themselves, a dashboard widget
and the settings frame report their height, a detail slot is a narrow column in
someone else's panel. The contracts differ, and on a filling surface the height
report is discarded, so a plugin cannot even discover the difference by trying.

trek:context now carries viewport: the surface by name, phone or desktop, the
fill flag, and the insets the host already keeps clear. The insets describe
space that is held free, not padding to add — the mobile trip tab is the only
surface with chrome floating over it, and the numbers come from the same
variables the native tabs use so the two cannot drift.

* Seat the plugin tab like a native one

The mobile plugin tab kept clearance at the bottom and none at the top, so the
plugin's first row sat under the status bar and behind the floating back button.
The bottom value was a copied 84 that ignored the safe area on the devices that
have one, and the padding sat on the container rather than in the scroller.

Both clearances now come from the variables TabScroller uses, so the tab cannot
drift away from the native ones. The frame also states its surface, and pins
color-scheme for the reason both settings mounts already pin it: Chromium paints
a white canvas behind a transparent frame otherwise.

The tab also had no name anywhere on screen. It is not in the dock, so nothing
lit up there either, and the header had a branch for every other tab. It gets
the same treatment, built from the tab entry the planner already assembles.

* Keep the widget card intact on the phone

The card is built from the dashboard's glass vocabulary, which is scoped to
.trek-dash. The phone dashboard is a different route and mounts these widgets
outside that subtree, so background, border, shadow and blur all resolved empty
and the widget stood on the gradient as a bare title over an iframe. Each value
now falls back to the mobile token the surrounding cards are made of.

* Give the design kit a mobile layer

The kit was written for the desktop and stayed there: a filled card with a drop
shadow where the mobile design is translucent and border-led, inputs at 13px
which make iOS Safari zoom on focus and never zoom back, feedback on :hover
which never fires on a touchscreen, and one media query in the whole stylesheet.

It reads the new viewport field now and sets data-form-factor, data-surface and
data-fill on the document, plus the insets as variables. Everything mobile hangs
off data-form-factor="phone", so nothing changes on the desktop: the card loses
its shadow and takes the mobile surface tokens, targets grow to 44px, inputs to
16px, and pressing something scales it instead of waiting for a hover that will
not come. A filling surface anchors the body and scrolls an inner element,
because its height report is discarded by the host.

Plus the pieces a v4 screen is actually made of, so an author does not have to
reverse-engineer them: eyebrow, count pill, section header, status dot, round
icon button, field, segmented control. Each falls back to a global token where
the mobile family is absent, so they work on both.

* Document what a plugin needs on a phone

The guide had nothing on the subject: no mention of the second token family, of
who scrolls, of what sizes to expect, or that the kit brings almost nothing
responsive. It also promised more than it delivered, claiming the palette was
complete and that applying it matches the host "exactly" — fonts are named, not
shipped, so that was never true.

The new section covers the six surfaces and which of them fill versus report a
height, what insets mean (space already held free, not padding to add), the
mobile palette with the branch-on-absence pattern, and the four rules that
decide whether it feels native. It also says plainly that the dev preview cannot
show a phone width yet.
2026-08-11 16:51:02 +02:00
jubnl 1a945baebe refactor(server): shrink assignments.bridge to its two live exports 2026-08-11 16:46:52 +02:00
jubnl 8b7b91d814 refactor(server): let user-cleanup inject BudgetService and delete budget.bridge 2026-08-11 16:42:49 +02:00
jubnl 2b2c6462ec refactor(server): fold the in-container bridge edges 2026-08-11 16:32:02 +02:00
jubnl 0718bd1b0c refactor(server): shrink the seam bridges to their live exports 2026-08-11 16:23:46 +02:00
jubnl 7880eba6fb refactor(server): move the journey resources onto the registry and retire src/mcp/resources.ts 2026-08-11 16:15:12 +02:00
Maurice d1e3d8a75a feat(places): offer Waze and Apple Maps beside Google Maps (#1885)
* Offer Waze and Apple Maps beside Google Maps

A place could only be opened in Google Maps, and the request in #919 was for the
apps people actually drive with. Google keeps the precise link it always had:
ftid, then place id, then the details URL, and coordinates only as a last resort,
so it still lands on the entry inside a mall rather than on the roof. Waze and
Apple Maps take coordinates and nothing else, which is why they are absent for a
place with no position, and why they open with navigation armed instead of on a
place view.

No setting. Every app that can resolve the place is offered, except Apple Maps,
which is dead weight on Android and Windows and therefore only appears on Apple
platforms. iPadOS reports itself as a Mac; both sides of that ambiguity have the
app, so the check does not need to tell them apart.

The picker is a new component rather than the context menu, which has no notion
of a submenu. It is portalled because both call sites sit in panels that clip
their overflow, and it is measured before it is painted: near the bottom of the
window it flips above the trigger instead of running off the screen, and it is
pulled back inside when it would overflow to the right. With a single app left
to offer there is no menu at all, the button opens it directly, exactly as it
behaved before.

The three surfaces take it the way each has room for. The inspector button
collects them behind one entry, the day-plan context menu lists them flat
because a menu can grow, and the phone keeps its single circle and opens the
picker as a sheet.

* Move OpenStreetMap into the navigation menu, and name the destination

OSM was still a button of its own beside the one that now collects the map apps,
which is the arrangement the menu exists to end. It belongs with the others.

The bigger correction is what Waze and Apple Maps are handed. Both document a
query parameter next to the position, and the pair is what makes a destination
legible: coordinates say which place is meant, the name is what the driver reads
instead of a number. Neither gets a name without a position, because there are a
lot of places called "Bahnhof". Google is unchanged; it can already do better
than a name through its place id.

* Stop drawing the section count as a pill inside a pill

SectionHeader wraps its count in a CountPill, and all three callers passed one
in already, so every section header carried two stacked pills: a lighter
rounded background peeking out from behind the real one. Pass the number and
let the header do the wrapping.

* Give the traveler filter a label and a way back

The avatars floated between the toolbar and the first section with nothing to
say what they were, and once a filter was on the only way out was remembering
which face had been tapped. It is a labelled bar now, closed off by the same
divider the rows below use, with an explicit reset that appears while a filter
is active. Both texts already exist in all 23 languages.

* Line the traveler filter up with the section titles

It carried the sheet's usual 18px of horizontal padding while SectionHeader
right below it starts at 2, so the two labels sat on different left edges.

* Filter the place list by stars instead of sorting by them

Sorting put the best-rated place first and left every other one on the list,
which does not help when the question is "what does the group actually want to
do". It is a floor now, the same one the collections bar uses: All, or 5+ down
to 1+, with unrated places dropping out once a floor is set. The button carries
the active floor so the list is never quietly shortened.

The row it sits in used to render only when the trip had categories, which hid
the star control on a fresh trip. That is exactly where someone collects places
to rate, so the row is always there now and only the category dropdown depends
on there being categories.

places.sortByRating is replaced by places.filterByRating in all 23 languages,
since the old label described a behaviour that no longer exists.

* Keep the collections map when a filter matches nothing

Clicking a label with no places behind it made the map disappear and the page
reflow to full width. The layout hung off the FILTERED set: no visible places
meant nothing mappable meant no split. A filter should empty the map, not tear
it out, so the layout now asks whether the list has anything mappable at all,
while the markers keep following the filter.

* Give the label filters a tray of their own

Two changes to the filter row. Select and manage-labels drop their captions and
keep icon, title and aria-label, which puts them on the same footing as the
add-place button that was already icon-only.

The labels themselves were pill-shaped buttons with their own borders sitting
loose among square-cornered dropdowns, so a list with a handful of them read as
a pile rather than as one control. They share the filter buttons' radius and
type now, and sit in a recessed strip led by a tag glyph: the chips carry no
border of their own, because a frame inside a frame was half the noise. Manage
stays quiet at the end of the strip until hovered.

* Put the collections controls on one line

The filter row sat at about 31px while the buttons floating over the map are
38, and the icon-only add button was the odd one out even within its own row.
Scrolled to the top, where the row and the map's top bar are side by side, that
reads as three different heights.

One --col-ctl-h for both, fixed rather than derived from padding so a button
with an icon and one with a word cannot drift apart, and the row starts at the
same offset the map's own bar does. Phones raise the variable instead of
patching individual min-heights, which keeps the square buttons square.

* Offer Apple Maps on desktops that are not Apple

Apple Maps has had a web version since 2024, so the link resolves to a usable
map on Windows and Linux rather than to nothing. Hiding it there was a guess
about a limitation that no longer exists.

Android keeps the exemption: the web version works there too, but nobody
navigating from an Android phone reaches for Apple Maps, and the row of choices
is short on purpose.

* Move the label filter into the map's top bar

The filter row was running out of room while the map's own bar had space to
spare, so the labels ride up there next to the collapse button, in the same
glass as the controls beside them and scrolling sideways rather than wrapping.

They fall back into the filter row whenever no map is on screen — a narrow
window in list view, or a list with nothing mappable in it — so they can never
end up somewhere the user cannot reach. Both places render the same component
now instead of two copies drifting apart.

* Stop the active select button drawing a dark icon on a dark fill

Two rules with the same specificity claimed the same class. On a dropdown
`.open` means the panel is down, so the label stays dark; on the select button
it meant the mode is on, which fills it with the accent and needs the light
text. The dropdown rule sits later in the file, so it won, and the icon
disappeared into the fill the moment the button was clicked.

The state is `.on` now, which is what the other toggles on this page
(col-map-btn, col-iconbtn) already call it.

* Open the place in Google Maps, not the coordinates

Reported in #1278 and still reproducible: without a Google identifier the link
carried nothing but latitude and longitude, so Maps dropped an unlabelled pin
instead of opening the entry. The name and the address were both on hand and
both thrown away.

Google documents query=PLACE_NAME,ADDRESS for exactly this case, so that is
what the fallback sends now. A name with no address still falls through to
coordinates, because "Central Station" on its own would confidently open the
wrong city, and a pin in the right spot beats a link to the wrong place.

The precise paths are untouched: an ftid or a place id still wins, and they are
what a place searched through Google carries. This only changes what happens
for the ones that carry neither, which is every place added from OpenStreetMap,
by hand, or before those fields existed.
2026-08-11 16:00:03 +02:00
Maurice 8b1a238c51 fix(ui): sparkline stacking, wrapping sheet hint, translated phone entry view, Cloudflare docs (#1884)
* Put the atlas sparkline behind the text it was covering

The graphic is absolutely positioned and comes after the label, value and delta
in the DOM, so with no stacking order of its own the browser painted it on top
of them. On the dashboard's atlas row a status card is about 190px wide at
1440px, and the 80px sparkline covered more than half the delta line (#1787).
Give it z-index 0 and lift the three text rows to 1. It is decoration, so it
stops taking pointer events while we are here.

* Let a sentence-length sheet subtitle wrap

TileHeader truncates its subtitle to one line. That is right for a filename or a
date, and wrong for the reorder hint: between the 40px icon tile and the 34px
close button there is not enough width for a sentence, so it was cut off
mid-word (#1814), and every translation is longer than the English one. Opt in
with subWrap where the subtitle is prose; the file sheets stay single-line.

* Translate the phone entry view

MobileEntryView carried its own English label tables while the desktop view
resolved the same values through i18n, so a German reader got "Amazing" and
"Sunny" on a phone and the translated chips on a tablet (#1846). Which view
appears is decided by two different breakpoints, which is why it looked
arbitrary. The labels are keys now, the edit action, the photo counter and the
pros/cons headings go through t(), and the date formats against the chosen
locale instead of the browser default. No new keys: all of them already exist
in all 23 files.

The colour classes stay local. The desktop table carries raw hex values and no
dark-mode variants, so sharing one table would change how this looks.

* Name Google account linking in the Cloudflare bot section

The section claimed the block was specific to ChatGPT, so someone hitting it
with Gemini had no reason to read on (#1667). Google exchanges the code from its
own servers, which Bot Fight Mode blocks the same way, and the symptom differs
enough to be worth spelling out: the browser half succeeds and linking fails
afterwards, with a 200 on /api/oauth/authorize and no /oauth/token at all. Also
note that Bot Fight Mode ignores IP allowlists, which is the first thing people
try.
2026-08-11 11:48:01 +02:00
Maurice 8c8cc2a727 Require share_manage for the two public credentials (#1883)
Both advisories are the same mistake: a route that hands out a bearer token
treated trip membership as authorisation. Membership lets someone read the trip
while they are signed in. It does not let them mint a URL that reads it without
an account, keeps working after they are removed, and can be rotated out from
under the owner's calendar subscriptions.

The calendar feed token now sits behind TripAccessGuard plus
@RequirePermission('share_manage'), the same shape the rest of the trip-scoped
controllers use, which also replaces a hand-written access check that GET never
called at all. GET is gated with the writes because it returns the credential
itself, not its status. The share link's GET does what its own POST and DELETE
already did, and what the invite link next door already does for exactly this
reason.

Two things had to come with it or the gate would have been decoration. The MCP
tool get_share_link read the same token on membership alone, so it would simply
have become the new way in. And feed_token rides along in every trip payload
because TRIP_SELECT reads t.*: any member could have read the value out of
GET /api/trips/:id, the summary tool or the plugin RPC. It is blanked once in
the select and stripped in the two places that select the row directly.

The three service writes take the acting user and scope their UPDATE to a trip
that user can reach. The guard decides who may manage the credential; this only
makes sure a caller arriving another way cannot write to an id it guessed.

Client side, "Subscribe to calendar" is now behind the same permission on
desktop and mobile. Without that a member would have kept an entry whose enable
button silently does nothing, because IcsSubscribeModal swallows every non-200.
The one-off ICS download stays where it is: that is a file they may already read.

Instances that want members to manage these links can lower share_manage to
trip_member in the admin panel, which the tests cover.
2026-08-11 11:15:32 +02:00
Konstantinos Thermos 073e55cbe4 fix(auth): redirect authenticated users away from /login and /register (#1822)
Landing on /login while already signed in, by typing the URL or going back, showed the login form again (#1810). PublicRoute takes a redirectAuthed flag on those two routes and sends the visitor to the start destination instead, capturing the auth state at mount so a fresh login is left alone during the takeoff animation. Only from a bare URL: ?redirect=, ?invite=, ?oidc_code= and ?oidc_error= all have to reach useLogin intact.
2026-08-11 10:21:58 +02:00
Konstantinos Thermos b4f9e96cc9 fix(feeds): use per-endpoint zones for transport times in the ICS feed (#1874)
A transport also carries a top-level reservation_time, and that branch derives its zone from the linked place, which a flight does not have, so both ends landed as floating times in the subscribed feed (#1453). Consult a timed departure endpoint first instead and keep the IANA zone per side. When the arrival side has no clock of its own, the DTEND still comes from reservation_end_time, so a rental imported with only a geocoded pickup keeps its multi-day return.
2026-08-11 10:17:52 +02:00
bear 1edb531549 fix(auth): send Referer on the Google Maps key validation request (#1862)
The admin panel's test button reported a key with an HTTP referrer restriction as invalid, because the validation call was the one Google request that did not send a Referer. It sends the same header googleFetch already sets for every real request.
2026-08-11 10:17:28 +02:00
Maurice b56dcec118 Type the cron registrar stub so the tests typecheck again
typecheck:tests landed with the contract ratchet; the scheduler migration
merged after it, and its idempotency-cleanup test stubs register() with no
parameters. Reading calls[0][2] back off a no-parameter mock types every
recorded call as [], which is a tuple index error, so dev has been red since.
Give the stub the real register() signature and drop the cast it needed.
2026-08-11 09:58:37 +02:00
Maurice 8e4261ed81 Delete four bridges with no consumer, and gate what runs outside the container
Both were left open by the audit and are closed here.

days, packing, photos and reservations each built a service outside the
container at module load for nobody: only their own delegation tests imported
them, and three domain docblocks were still telling readers to go through them.
photos.bridge's own comment pointed at src/services/, a directory deleted two
PRs ago. The cases that exercised them kept their assertions and call the
service the bridge delegated to, so nothing that was proved stopped being
proved. Twelve bridges remain, each with a live out-of-container caller.

Coverage measured src/** but gated only src/nest/**, so the boot path, the
scheduler, the MCP handler and the migrations sat under no floor at all. That
is the same 9,602 lines where this audit found its worst defect, and a
swallowed EADDRINUSE is exactly the kind of thing an untested boot path hides.
Seven globs now pin what is measured today. They are floors to raise, not
targets that have been met: src/*.ts is at 39% statements and src/db at 38%
branches, and writing those numbers down is the point.

src/demo/** is deliberately left out. It measures 0%, and a floor of zero
asserts nothing; it needs tests before it needs a threshold.
2026-08-11 09:58:37 +02:00
Maurice 4dce8b9a7f Correct the ephemeral-token store's own justification
The docblock said the ws handshake consumes tokens from outside the container.
That was true when it was written and stopped being true one PR later, when the
transport became a gateway that injects this service.

The store still has to be module state, for a reason the comment did not give:
four bridges build EphemeralTokenService by hand, and per-instance state would
give each of them an empty Map, so a download token minted through a bridge
would never validate. An audit lens flagged the stale reasoning; the
requirement survives it.
2026-08-11 09:58:37 +02:00
Maurice 3a7c8e1d26 Make a bind failure fatal again, and undo four contract tightenings
An adversarial audit over the three merged migration PRs. Sixteen findings
survived triple refutation; these are the ones with a defect behind them.

The serious one is mine, from the gateway move. ws registers
`error: this.emit.bind(this, 'error')` on the http server it attaches to, and
the adapter now runs inside buildApp, before listen(). So from that moment the
http server HAS an error listener and Node stops throwing on one. The adapter's
handler was `() => {}`, which meant EADDRINUSE was forwarded there and dropped:
the process survived, the listen callback never ran, so the banner never
printed and the scheduler never started. A container that looks healthy and
serves nothing. Reproduced, then fixed at both ends: index.ts owns the fatal
decision on the http server, and the adapter logs instead of swallowing.

Four DTOs were tightening contracts rather than describing them, which is the
thing the pipe-before-handler ordering makes easy to do by accident:

- OauthClientCreateDto replaced 'Name is required' with a Zod field report, and
  pre-empted the handler's 403 on an instance with the MCP addon off.
- BookingImportConfirmDto demanded per-item fields the service never reads, so
  a caller assembling items itself got a 400 about `source`.
- TagCreateDto/TagUpdateDto typed name and color as strings; the legacy route
  bound whatever arrived, so a numeric name used to create a tag.
- PluginUserSettingsUpdateDto typed config as a record, where the handler
  answers 200 for anything it cannot use.

Two of my own tests were false-green, which the audit demonstrated by mutation
rather than by reading. WSGW-008 asserted on frames that never carry the user
object at all, so it passed with the password_version strip deleted; it reads
the retained identity now. GEO-009 made the same assertion before and after the
write; it distinguishes a cached negative from a miss now, through a hasCached
seam that says what getCached cannot. Both were re-run against the mutation
they exist to catch, and both fail on it.

booking-import geocodes every venue and endpoint in one request loop, up to
thirty sequential calls. Folding maps onto the shared Nominatim cursor put that
loop on the interactive lane, so an import starved every concurrent keystroke
for half a minute. It takes the background lane now, which is what the two-lane
split was built for.

Also: the README's in-container bridge list had outlived one entry and was
missing another, and the journey e2e still mocked a module this branch deleted.
2026-08-11 09:58:37 +02:00
jubnl b82651bf93 Merge remote-tracking branch 'origin/dev' into dev 2026-08-10 22:38:05 +02:00
jubnl f04023b965 delete unwanted files 2026-08-10 22:21:25 +02:00
jubnl 3a48dd7c1c docs: bring the scheduler docs in line 2026-08-10 22:20:11 +02:00
jubnl 08dac57577 refactor(server): migrate plugin jobs off node-cron
The plugin host's job scheduler now uses the cron package (the one
@nestjs/schedule wraps), so node-cron and @types/node-cron leave the
dependency tree entirely. Verified against both real libraries that the
accepted cron grammar is identical — no plugin's declared schedule tightens —
and pinned that with an unmocked compat test.
2026-08-10 22:16:22 +02:00
jubnl f43aed762d refactor(server): retire src/scheduler.ts 2026-08-10 22:04:23 +02:00
jubnl de5d196344 refactor(server): move the cache sweeps, idempotency purge and airtrail poll in-container 2026-08-10 22:02:54 +02:00
jubnl 78b63972d0 refactor(server): move the version check and demo reset into the admin domain 2026-08-10 21:56:13 +02:00
jubnl 1cc46f11b5 refactor(server): move the reminder crons onto the injected notifications 2026-08-10 21:49:27 +02:00
Maurice d981719941 Serve /ws from a Nest gateway with its own adapter
The transport was a hand-rolled setupWebSocket(server) that index.ts kicked
off with a dynamic import after listen(). It is a @WebSocketGateway now, with
DatabaseService and EphemeralTokenService injected instead of imported, and
the heartbeat owned by a lifecycle hook rather than an interval nobody stops.

The wire protocol does not move. Nest's WsAdapter dispatches on
{ event, data } and every deployed client sends { type, tripId }, so
TrekWsAdapter translates rather than asking clients to change. It also builds
the ws server itself instead of delegating: the base's port-0 branch uses
noServer and performs the upgrade from its own registry, which never consults
verifyClient, so delegating would have dropped the origin check and answered
101 to anyone. The per-socket error listener stays attached first, because an
unhandled one kills the process (#1576), and the flood guard counts every
inbound frame before parsing, because malformed frames are exactly the ones
worth limiting.

Four things had to stay where they were, and each is a silent failure if it
does not:

The socket registry stays module state. Seventeen bridges and
notifications.instance build `new RealtimeService()` outside the container;
rooms on a provider instance would leave their broadcasts going to an empty
map, with no error and no log.

The socket id stays a monotonic integer. The client echoes it as X-Socket-Id
and broadcast excludes the originator with Number(excludeSid). A uuid is NaN,
NaN === NaN is false, and every client would receive its own writes back,
which shows up as drag-and-drop that jumps rather than as an error.

buildApp creates the http server and binds the adapter before app.init(), and
callers take it from getHttpServer(). Nest binds gateways during init, so a
later adapter binds nothing; an adapter given the app binds to Nest's own
internal server, which this process never listens on. Both boot green with no
client able to connect.

RealtimeGatewayModule is imported by AppModule alone. RealtimeModule is
@Global, so a gateway there follows into every hand-assembled TestingModule,
and Nest's SocketModule then loads an adapter none of them set: it reaches for
@nestjs/platform-socket.io and calls process.exit(1). Eighteen e2e harnesses
died that way before the split.

src/websocket.ts survives as a re-export of the three fan-out primitives. 115
test files vi.mock that path to assert on broadcasts.

The two transport suites pass unchanged, and 30 new cases pin the pieces
directly: the pv and MFA gates, the numeric socket id, the plugin announcement
firing before the room check, both sides of the origin allowlist, and the
flood guard counting frames no handler wants.
2026-08-10 21:46:47 +02:00
jubnl 0ec611eb98 refactor(server)!: read the reminder gates per tick and drop the app-settings restart
The trip/todo reminder enable gates (notify_trip_reminder, notify_todo_due)
are now read inside each 9 AM tick instead of once at boot, so toggling them
takes effect at the next run without a restart. Saving notification settings
no longer re-arms anything: the auth-side startTripReminders() call and the
shouldRestartScheduler result field are gone (no controller consumed it, so
the PUT /api/auth/app-settings response shape is unchanged). The boot
'enabled/disabled' log lines are informational banners now.
2026-08-10 21:37:45 +02:00
jubnl c63d6430af refactor(server): move the auto-backup cron into the backup domain 2026-08-10 21:33:48 +02:00
jubnl 399bbda0fa feat(server): add the schedule wiring 2026-08-10 21:22:13 +02:00
Maurice ba7d55f999 Finish the contract ratchet, the upload bridge and the client leftovers
BODY_CONTRACT_ALLOW_LIST is empty. The last seventeen handlers type their
body with a createZodDto class, so the fail-closed boot gate has nothing left
to forgive; it still refuses a boot for a new handler that reads @Body()
without one.

Several of those contracts describe rather than constrain, and that is the
whole care in this change. The pipe runs before the handler, so a schema
stricter than the handler moves a user-visible answer:

- TagsController.create answers { error: 'Tag name is required' }, pinned
  byte-for-byte in two tests, and the pipe formats as `field: message`. The
  DTO leaves name optional and the handler keeps the check that owns the
  message. Same shape for the booking-import confirm body, which additionally
  gains per-item validation the handler never did.
- PluginsController.activate and install answer 503 when plugins are disabled,
  and check that BEFORE looking at the body. A schema that rejected first
  would turn that into a 400 for an operator whose server has plugins off.
- setEgressHosts reads a non-array `hosts` as "clear the list", which is the
  documented reset. Typing it as string[] would have removed a working admin
  action rather than tightened anything.
- PluginRoutesController.route answers { route: null } at 200 for everything
  it dislikes, so the client falls back to straight lines. A 400 there would
  turn a graceful degradation into a visible error.

files.bridge is gone. The multer options are built by
MulterModule.registerAsync from an AllowedFileTypesService leaf, so the
fileFilter reads the operator's extension list from the container instead of
from a second FilesService built outside it. Nest injects
MULTER_MODULE_OPTIONS with @Optional(), so a token that fails to resolve is
not a boot error: it silently falls back to defaults, with no filter and no
size cap. FILE-020 already proved that for trip files by uploading an .svg
over HTTP and expecting 400; journey uploads had no such case, and now do.
VIDEO_UPLOAD is untouched, including the filename callback that forces .jpg
regardless of what the client sends, which guards the public share proxy.

PluginFrame gets an onError. A frame whose document fails to load left a
blank element at full height, which reads as a hung app rather than as a
plugin that is gone.

typecheck:tests runs in CI now, with the test tree at zero errors. tz-lookup
is left as it is: the build puts it in a lazy helpers chunk, not the entry
chunk, and the remaining split would trade a synchronous render for an async
one to move 172 kB that is already off the critical path.
2026-08-10 19:54:46 +02:00
Maurice 7078ac7a4d Keep the mobile FX widget off the network after unmount
Client CI was red on this branch while dev was green, and no client file had
changed. The 536 test files all passed; the job failed on three unhandled
rejections, all `window is not defined` from MDashWidgets, attributed to
whichever case happened to be running when they landed.

The widget fetches api.frankfurter.dev directly and sets state from both
handlers with no unmount guard. MSW runs with onUnhandledRequest: 'warn', so
that request was never intercepted: it left the runner, came back after the
environment was gone, and React tried to schedule an update against a torn
down tree. Load dependent by construction, which is why dev happened to pass.

Two halves, because either alone leaves the other broken. The widget now
aborts in flight work on unmount and on refetch, and treats an abort as "no
one is waiting" rather than as a failure to report. That matters outside the
runner too: this is a third party call on a mobile screen a user can leave
immediately.

The handler set gains the third party endpoints the app calls directly rather
than through /api, so no suite reaches the real host. An empty rate list is
the shape the widget already handles.

Also clears twenty more test files, each at its root cause. Two more were
passing for the wrong reason: oidc.service.test.ts built AuthService with an
AtlasService in the membership slot, undefined for userCleanup and mailer,
and the mailer stub in the tokens slot, the same shift admin.service.test.ts
had. And CAL-010 claims to serialise a flight byte for byte, but
createReservation ignores reservation_time, so the row is written with a null
time and CalendarService drops it: the recorded snapshot is truthful about
the data the case builds and silent about the flight it advertises.
2026-08-10 19:54:46 +02:00
Maurice 49c3c57c31 Give the ephemeral tokens a provider, and clear the next wave of type errors
ephemeral-tokens.ts was free functions, and AuthService owned the cleanup
timer only because it happened to be the domain that mints tokens. Issuing
them and sweeping them are different jobs, and FilesService and TokenService
mint or consume them too.

EphemeralTokenService owns the lifecycle and hands the three operations out.
The store and the interval stay module state next door, and that is
load-bearing rather than a leftover: src/websocket.ts consumes ws tokens on
the upgrade handshake, outside the container, before any injector is in
reach. Per-instance state would hand it an empty Map and close every
connection with 4001, which no test would catch because the ws suites drive
the transport directly. WS-027 through WS-029 pin the session gate that runs
through exactly that path.

The MAX_STORE_SIZE back-pressure is the second reason: createEphemeralToken
returns null at 10,000 live tokens and TokenService turns that into a 503. A
second store would raise the real ceiling with nobody noticing.

Its own leaf module rather than a provider in AuthModule: TokensModule mints
download and ws tokens as well, AuthModule already imports TokensModule, and
TokensModule is documented as a deliberate leaf that three graphs reach for.
Same shape as MailerModule, which broke AuthModule <-> NotificationsModule.

Twelve hand-wired construction sites in tests needed the new collaborator.
The typecheck named all twelve; before this branch they would have been
undefined at runtime.

Sixteen more test files are cleared here too, each at its root cause. Four of
them were passing for the wrong reason:

files.controller.test.ts drove two handlers with shifted arguments, a string
tripId where the trip row belongs and undefined where tripId and id belong.
It stayed green because FilesService is fully stubbed and the stubs ignore
what they are given, so DELETE /:id/permanent and DELETE /trash/empty never
covered that the trip row reaches the permission check.

conflictUpdate.test.ts was missing real collaborators on PlacesService and
PackingService. None of the exercised paths reach them, so it passed by luck.

assignments.service.test.ts builds its service by hand, and the one consumer
of the collaborator it was missing swallows every throw in a bare catch. A
broken journey reconciliation would still have shown 28 green tests.

scheduler.test.ts mocks two modules with paths that resolve one directory
above the server root, so they mock nothing at all. Harmless only because
src/scheduler.ts imports neither.
2026-08-10 19:54:46 +02:00
Maurice ff286edc45 Widen the test stubs the typecheck rejected
conditions.test.ts lost noTrips when the condition kind landed; oauth.test.ts
lost the RFC 8707 resource indicator when it became required. Both are
fixtures that stopped matching a contract that grew, and both are filled in
with the value that keeps every existing case reading as it did.

budget.service.calc.test.ts typed its prepare mock from the default
implementation rather than from prepare's own signature, so all ten later
mockImplementation calls had to reproduce that exact three-member literal.
One stmt() helper names the widening instead.
2026-08-10 19:54:46 +02:00
Maurice f691da8f7e Read online users through the facade, not a lazy require
admin.service.ts reached getOnlineUserIds with require('../../websocket')
inside a try/catch — the last lazy require of the ws singleton in the tree,
and exactly what RealtimeService exists to prevent. The facade gains the
third pass-through next to broadcast and broadcastToUser, delegating at call
time like its siblings so per-file vi.mock stubs keep flowing through.

The catch stays at the admin call site rather than moving into the facade: an
admin list that shows everyone offline is a better answer than a 500, but
that judgement belongs to this caller, not to every future one.

Typechecking the tests turned up two more things behind the arity errors.

admin.service.test.ts built AuthService with an AtlasService in the
membership slot and no mailer at all, so its last four collaborators were
shifted by one. Nothing failed, because no case there reaches a path that
uses them.

Its getOidcSettings/updateOidcSettings wrappers referenced methods AdminService
no longer has. Nothing called them: OIDC settings moved to OidcService and the
two cases moved with it (ADMIN-SVC-047/048 now live in oidc.service.test.ts).
Only the wrappers stayed behind.
2026-08-10 19:54:46 +02:00
Maurice 4780a7be90 Typecheck the test tree, and fix what it found
server/tsconfig.json has include: ["src"], so tests/ was never typechecked.
A service that gained a constructor parameter did not break its hand-wired
call sites at compile time — the argument arrived as undefined and threw at
runtime, if anything read it at all. The notifications sweep hit 46 such
sites and only a stash-diff against a throwaway tsconfig found them.

tsconfig.tests.json makes that permanent. It is not wired into CI yet: the
tree still reports 170 errors, and turning it on now would block every
unrelated PR.

Three of the first fixes were not type noise.

DAY-SVC-034 asserted that createAccommodation rolls back a failed
reservation insert. That method moved to AccommodationsService in the
days/accommodations split, so the case was calling undefined — which throws,
which satisfied .toThrow(). The rollback has been unchecked ever since,
while the case reported green. Repointed at the owner; the invariant holds.

host-rpc.test.ts typed its response as RpcResponse & Partial<RpcError>.
Both carry ok, one as true and one as false, so the intersection is never
and all 97 property reads in the file were errors nobody could see.

auth.controller.test.ts passes {} as the body in cases that stub the service
and assert on the mapping around it. That is correct as written — the point
of several of them is that the error path never reads the body — so anyBody()
names the intent instead of inventing fields the assertions do not read.
Three changePassword calls were genuinely missing the @Res() argument.
2026-08-10 19:54:46 +02:00
Maurice 6cf5760383 Inject the notification senders instead of importing them lazily
Nine fire-and-forget sends reached notifications.bridge through
import('...').then(({ send }) => ...). The laziness was working around a
module cycle that no longer exists, and it cost twice: it hid the edge from
the module graph, and it handed each send a NotificationsService built
outside the container rather than the container's own.

The sweep needed a method rather than a grep. tests/ sits outside
tsconfig's include, so a service that gains a constructor parameter does not
break its hand-wired call sites at compile time — the argument arrives as
undefined and throws inside the send. Adding tests to a throwaway tsconfig
and diffing the errors before and after named all 46 sites exactly. The same
config reports 288 pre-existing errors in the suites, almost all deliberate
partial constructions in the harnesses, which is why tests/ is not in the
build's include; that cleanup is its own piece of work and is written down
in the nest README.

notifications.instance.ts holds the out-of-container instance, apart from
notifications.bridge.ts on purpose: several suites replace the bridge
wholesale with vi.mock(..., () => ({ send })), and a sibling bridge taking
its instance from there would break under those mocks.

The suites that assert on a send now hand their spy to the injected service.
The reservations e2e overrides the provider, because its app resolves the
real one out of its own container and never saw the module mock.
2026-08-10 17:08:54 +02:00
Maurice c58d797117 Write the 404 Immich streaming only returned
streamImmichAsset answered { error, status } when the effective owner had no
Immich connection stored, and left the response untouched. All four callers
ignored that return, so the client got no status line and waited out its own
timeout. Its Synology counterpart has always written through
handleServiceResult; this is the same shape.

Every other exit already went through pipeAsset, which writes on all paths
including its catch, so the method is void all the way down now — and the
PhotoProvider interface drops the error return it was carrying purely to
describe this defect.

Three cases pin it, including the one that matters for shared albums: the
connection that gets read is the OWNER's, so a viewer who happens to have
Immich configured must not paper over an owner who does not.
2026-08-10 17:08:54 +02:00
Maurice fc9742357b One photo provider interface instead of three switches
Dispatch to a photo backend was `switch (photo.provider)` in
photo-resolver.service.ts twice and, in journey-public.controller.ts, an
if/else that handed anything not named immich to Synology — where it 404'd
from inside that provider's id parser. Adding a third backend meant finding
all three.

PhotoProviderRegistry.get(id) replaces them, over adapters registered under
the PHOTO_PROVIDERS token in memories.module.ts. Adapters rather than
`implements` on the services themselves: ImmichService and SynologyService
are also the album and browse surfaces, and folding the interface into them
would drag their argument orders and return shapes along.

PhotoAssetRef is the part worth having. The two services took the same three
values in different orders — Immich as (userId, assetId, ownerId), Synology
as (userId, targetUserId, photoId) — so every dispatch site had to remember
which was which, and a transposition would have compiled and served another
user's photo. One place builds the ref, one place decrypts the Synology
share passphrase.

`local` stays an explicit branch at both dispatch sites: the bytes are on
this disk, there is no row for it in photo_providers, and its asset info
comes from the stored row rather than from a backend.

The rename memories/ -> media/ is deliberately not here. It is import churn
across 275 files, and coverage-thresholds.mjs buckets on one path segment,
so merging photos/ in would collapse two ratchet entries into one and keep
the looser of the two.
2026-08-10 17:08:54 +02:00
Maurice 2d0eca7938 Let MCP addon gates read an injected service
Nine *.mcp.ts files reached through addons.bridge for one boolean each, and
platform.routes plus the legacy registrars still do. That was never a
preference: a decorator's options object is built while the class is being
defined, so the `when:` closure had no `this` to reach an injected service
through, and the only thing in scope was a module-level singleton that
builds its own AddonsService outside the container. Nine domains hid an edge
to the addons domain from the module graph to answer "is this addon on".

@trek/nest-mcp hands the gate its declaring instance now, resolved at
attach rather than captured at module load, and addons/addon-gate.ts turns
that into addonGate(ADDON_IDS.X) over the injected service. The option is
declared with method syntax so a predicate written against its own
controller class stays assignable.

Two more edges went the same way, both checked for cycles first:
reservations.mcp injects AssignmentsService (AssignmentsModule never
reaches back), atlas.mcp and journey.mcp inject AuthService.

The tests that forced a toggle by mocking the bridge module now write the
addon row the admin panel writes, so they exercise the real read.

Four in-container uses stay, each documented in the nest README: places.mcp
(a real Days -> Places -> Assignments cycle), budget/packing/costs against
trips.bridge, user-cleanup against budget.bridge, and the two multer
interceptor options, whose fix is MulterModule.registerAsync.
2026-08-10 17:08:54 +02:00
Maurice 328b5fcb2b One Nominatim client for atlas and maps
Atlas throttled to the 1.1s OSM asks for and cached its answers in a 50k
map; maps did neither, so five interactive routes fired straight at the
service while the two atlas backfill loops politely waited their turn.
From Nominatim's side that is one instance ignoring the rate limit.

Both go through geo/nominatim.client.ts now. Three things the naive merge
would have got wrong:

- The shared client sends UA, the instance-identifying User-Agent, not the
  generic string atlas used. A shared generic UA is throttled harder.
- One cursor across five interactive routes and two background loops would
  have put a keystroke behind however many places the backfill enqueued, so
  there are two lanes over the one cursor and background yields.
- The cache key carries the query shape. Atlas asks at zoom 3 and 8, maps at
  18; a coordinate-only key answers a street lookup with a country.

The throttle cursor and the cache stay module state, the way the permissions
and FX caches do, because the bridges build their collaborators outside the
container and a second copy of the cursor is the bug being removed here.
GeocodingService owns only the cleanup timer's lifecycle, which atlas used to
start as an import-time side effect.

Also folds the three haversine implementations into common/geo.ts, keeping
the clamped form: asin of a value a hair over 1 is NaN, and a NaN distance
fails every comparison it feeds instead of throwing.

tests/setup.ts zeroes the interval. Nineteen maps cases drive these paths
back to back with real timers and were sleeping nineteen seconds for it.
2026-08-10 17:08:54 +02:00
Maurice 55da861de6 Put the reservation imports behind one door
Two controllers declared the identical @Controller string from two modules and
each rebuilt the trip access check its own way: booking-import ran it as a
guard chain, integrations ran it by hand inside the handler, and
BookingImportService carried a third copy that nothing called at all.

They never actually collided - the four file-upload sub-paths and the one
AirTrail sub-path are disjoint - so this is not a bug fix. It is one access
path instead of three implementations of the same one, and the dead copy is
gone.

The addon requirement sits on the AirTrail handler rather than the class.
Class-level would have 404d the four upload routes whenever AirTrail happened
to be off. AddonGuard still runs first in the chain, because a disabled addon
has to answer 404 to an anonymous caller before anything answers 401, and
ADDON-GUARD-006 pins both halves of that.

One deliberate behaviour change: the AirTrail route now gets its trip 404 from
the guard, ahead of the body 400, because a guard runs before the pipe. That
is the ordering every other trip-scoped controller already has and nothing
pinned the other way for this route.

The class rename would have stopped the server booting: the body-contract gate
keys on ControllerClass.methodName and throws on stale entries. Three entries
renamed, and the list re-sorted - GATE-006 caught that the rename had broken
the ordering it asserts.

booking-import lost the controller from its coverage denominator, so the
branch floor was about to fall. It goes up instead: the off-request parse had
no case for its catch arm, so a throw would have left the job on 'running' and
the widget spinning, and the binary probe behind /api/health/features had no
test at all - that one boolean decides whether the client offers booking
import.
2026-08-10 17:08:54 +02:00
Maurice 4965281644 Delete src/services and wall off the way back
The directory is empty after the airtrail fold, so it goes. The ESLint rule is
the part that matters: the directory disappearing is not what keeps it gone,
because the way it grew was one file at a time, each one reasonable on its own.
Any import of a services/ path is an error now, with the message pointing at
the recipe in the module guide.

Proved rather than assumed: a probe file importing the old airtrail path fails
the rule, and the whole package still lints with zero errors.

Not changed: the 91 provenance comments in shared/, client/ and server/ that
name a services/*.ts file. They are statements about where a schema or a
service came from, and they stay true as history. Rewriting them all would put
91 files of noise over the change worth reviewing, and the paths they cite are
already gone from the tree, which is the honest signal that they are history.
2026-08-10 17:08:54 +02:00
Maurice 19f999329e Fold the last legacy service into its domain
src/services/ held one thing: airtrail/, 1305 lines of plain functions over the
better-sqlite3 singleton. They are four providers and two pure helper modules
now, and the directory is empty.

Five hidden edges go with them. The db singleton in three files, the raw
broadcast from src/websocket in two, and the reservations, addons and audit
bridges - all module-level imports that the module graph could not see.

The scheduler stops reaching for it at call time. scheduler.ts ran
require('./services/airtrail/airtrailSync') inside the cron tick, so a wiring
mistake surfaced as "AirTrail sync tick failed" every five minutes rather than
at boot. It takes the dependency through SchedulerDeps now, the same structural
contract backups and placePhotos already used.

One edge stays a seam, deliberately. AirtrailSyncService needs
ReservationsService, because the inbound pull applies remote changes through
the real reservation update path - endpoint restamping and all - and
reimplementing that against raw SQL would be worse than the coupling. The
write-back trigger runs the other way, from reservations.controller, and
injecting there would close
ReservationsModule -> AirtrailModule -> ReservationsModule. airtrail.bridge.ts
holds that one function with the reason in its docblock, rather than a
forwardRef hiding it.

The write-gate suite was module mocks over free functions and is stubs through
a constructor now; it gained four cases on the way (auth failure, remote
delete, disconnected owner). The domain ratchet goes from 19/0/10/19 - set when
integrations was two thin controllers - to 69/52/72/71, which is what the
folded code measures. It is a 1:1 move, so it arrives with the coverage its
tests always gave it.
2026-08-10 17:08:54 +02:00
Maurice f601bd8d47 Gate the enrichment domain at what it actually measures
place-enrichment landed on dev without an entry in the per-domain coverage
table, so it sat on the 80/80/80/80 catch-all while measuring 99.5 statements
and 99.0 branches. That is the exact hole the per-domain ratchet was
introduced to close: one well-covered domain quietly carrying nineteen points
of headroom nobody would notice losing.

The rest of that work needs nothing from this branch. It injects
DatabaseService rather than reaching for the db singleton, reads no
process.env, carries a Zod contract in @trek/shared with a colocated DTO, adds
no allow-list entry, and its controller declares @UseGuards(JwtAuthGuard) -
which is what keeps it authenticated under the default-deny guard this branch
introduces, since a declared chain makes the global guard stand down rather
than answer first.
2026-08-10 13:38:26 +02:00
Maurice 11afd3648e Put the coverage back where the code went
Every split on this branch dropped the giving domain's ratchet without a line
being tested any worse: coverage follows the source file, so when
well-covered code leaves, the weaker branches that stay behind become the
whole denominator. days fell from 82 to 77.5 percent branches that way, feeds
to 76.7, trip-read-model to 62.5.

The rule in vitest.config.ts is that the only way past a number is to raise
it, so these are tests, not edits to the thresholds. days is back at 92.1,
calendar at 96.2, trip-read-model at 100.

feeds and trip-members and trip-read-model had no unit test at all - feeds'
whole threshold rested on one e2e, which is exactly the file whose contract
this branch rewrote. They have one now, and the read-model test pins the
thing in that file that would leak data if it regressed: getTripSummary and
bundle both thread the viewer id into packing.listItems so another member's
private items stay hidden, and nothing asserted that before.

The five thresholds this branch introduced move up to what they now measure,
with a few points of slack. The pre-existing entries are left alone on
purpose: they were generated from a Linux run, CI measures there, and a
handful of branches are platform-specific - raising them from a Windows
measurement is how you get a failure that reproduces nowhere local.
2026-08-10 13:38:26 +02:00
Maurice 22ee0bafdc Bring the module guide in line with the guards
The guard section still said BE-Phase 2 was in progress and listed
TripAccessGuard, default-deny and the MFA policy as open. All three landed on
this branch, and two of them changed how a contributor is supposed to write a
controller, so the file that tells them how needs to say it: what
GlobalAuthGuard does with a declared @UseGuards chain, why places splits along
"does this handler take a DTO-typed body" rather than along the domain, and
why TripOwnerGuard must not go near PermissionsService.

It also still pointed at middleware/mfaPolicy.ts in two places, which this
branch deleted.
2026-08-10 13:38:26 +02:00
Maurice e5c99903ea Pin the request body ceiling instead of inheriting it
The Express shell set a 100kb limit and stopped applying it the moment the
Nest instance took over parsing: bootstrap passed bodyParser:false and
nothing replaced it. The number happened to stay the same, because it is also
the framework default - but it lived nowhere anybody would look for it, and
one NestFactory default away from silently becoming something else.

It goes through Nest's own parser rather than a second express.json(),
because that parser carries the verify hook that keeps req.rawBody, and the
plugin webhook routes need the exact payload bytes to check a provider's
HMAC. Adding a parser in front of it would have taken rawBody away without
failing anything.

MISC-009 and MISC-010 pin it from both sides.

The bodyParser flag on applyGlobalMiddleware goes with it: one caller, which
passed false.

Not moved: cookieParser stays on the raw Express layer rather than becoming a
configure(consumer) middleware. The platform routes - the OAuth consent flow
among them - are registered on that instance before Nest routing exists, so a
Nest-slot cookie parser would simply not run for them. helmet, CORS,
compression, the forced-HTTPS 301 and the res.on('finish') logging stay for
the same reason: they have to see requests Nest never routes.
2026-08-10 13:38:26 +02:00
Maurice be8081c3b4 Enforce the MFA policy as a guard, and stop 403ing public routes
The last live non-shell middleware verified the JWT and read the users row a
second time on every /api request, then threw the loaded user away - two
jwt.verify calls and two SELECTs per authenticated request, because it ran
before Nest and could not see what the auth guard had already resolved. The
guard reads req.user.

It also corrects an asymmetry that had grown quietly. The middleware decided
on two literal path lists, and every public /api endpoint added after those
lists were written - /api/config, /api/help/*, the public journey and share
routes, /api/health/features - answered a logged-in user without MFA with a
403 while answering the same request from a stranger perfectly well. Nothing
intended that; the lists simply stopped being maintained. Keying on @Public()
means the exemption cannot drift from the route again. This is a deliberate
behaviour change and the only one here.

@MfaExempt carries the reason for the other kind of exemption: the routes
that let somebody complete setup. Without them the policy locks out every new
account, which is why the middleware had its second list.

One ordering trap on the way: global guards run before route guards, so the
auth guard standing down on a controller with its own @UseGuards chain left
req.user unset when the MFA guard ran behind it, and it waved everything
through. AUTH-020 caught it. It resolves the user now without refusing - the
declared chain still owns the 401.
2026-08-10 13:38:26 +02:00
Maurice ffb73fac20 Turn protection from opt-in into default-deny
Nothing in src/nest/** was an APP_GUARD. Every protected route said so
itself, 62 times, which means a forgotten @UseGuards was a silent bypass
rather than an error. GlobalAuthGuard inverts that: a route is authenticated
unless it carries @Public() or @OptionalAuth(), or declares its own guard
chain.

Reading the declared chain is what makes this behaviour-neutral. Nest runs
global guards before route guards, so without that check the global guard
would 401 an anonymous request to an addon-gated controller before its
AddonGuard could answer 404 - inverting an order four e2e suites pin, and
leaking which addons are installed. A controller that declared a chain keeps
deciding for itself.

It is a new class rather than JwtAuthGuard gaining a Reflector: that guard is
constructed positionally in its own test with a context stub that has no
getHandler, and the two want opposite behaviour anyway.

@Public() takes a required reason, and the reasons are the diff worth
reading: the invite token IS the credential, help assets are loaded by <img>
which cannot send one, logout has to work with an expired session or nobody
can sign out.

The real deliverable is the inventory. validateRouteGuards runs in buildApp
next to the body-contract gate and refuses to start when a route answers
anonymously without being on a reviewed list of 25 - or when a listed one no
longer does. An allow list nobody prunes stops being a list.

Default-deny found two controllers on its own: SharedController and
OidcController both sit in a file next to a guarded controller, so a
file-level search for unguarded ones missed them. 37 tests went red and named
them. That is the mechanism working before the change shipped, not after.
2026-08-10 13:38:26 +02:00
Maurice ac49191ec8 Describe the journey bodies instead of tightening them
Sixteen of the thirty-four entries in the body-contract allow list were the
JourneyController, and a strict schema already existed for six of them. That
is the trap: eight of these handlers validate their own body and answer with
their own text - 'Title is required', 'trip_id required', 'provider and
asset_id required', 'entry_date is required', 'user_id required' - and those
bodies are pinned by e2e and integration cases. Adopting the strict schemas
would have the pipe answer first, with a different body. That is a
client-visible change wearing the costume of a refactor.

So the schemas follow the memories doctrine instead: loose objects, and every
field the handler type-checks itself is unknown here. They describe the shape
that already works, which is what the boot gate needs, and every hand-rolled
400 keeps answering. The spec inverts accordingly - it now asserts that each
schema ACCEPTS what the handler still rejects, which is the property that
makes the claim checkable.

Two of them are optional at the top level, and that is not decoration: a
multipart upload with files and no text fields arrives with no body at all,
and JOURNEY-INT-045 pins 'No files uploaded' for it. Without the optional the
pipe answered 'body: Invalid input: expected object' instead. The suite
caught it.

Backup joins them - its schema was already a passthrough, because the service
normalises the body itself.

Allow list: 34 entries down to 17. Tags and the two OAuth entries stay for
now; tags reads @Body('name') per property, which is a signature change
rather than a contract one.
2026-08-10 13:38:26 +02:00
Maurice 4591645316 Make ownership a guard instead of four hand-written checks
src/nest/** had no owner guard at all. Handing a trip over and creating or
deleting guests each asserted access.user_id !== user.id inline, and the
string they answer with differs per route, so the message travels in the
metadata rather than living in the guard.

TripOwnerGuard deliberately does not inject PermissionsService.
checkPermission returns true for every admin and the trip actions are
admin-lowerable, so routing ownership through it would quietly let any admin
transfer somebody else's trip. OWNER-004 pins that: an admin who is not the
owner gets 403.

places gets protection on the five routes where a guard is provably safe:
list, get, unrate, image and delete. Not on create, update, the imports or
the uploads. A guard runs before the pipe, so guarding a route with a
DTO-typed body would answer the trip 404 where the suite pins a 400 - that
includes PUT :id/rating, which is why the rating pair is split. The three
multipart routes are excluded for the other reason: answering while the
client is still streaming destroys the socket, and the caller sees
ECONNRESET instead of a status.

DELETE :id carries @RequirePermission('place_edit'). The first attempt at
this lost that check and answered 200 where 403 belonged, so the e2e asserts
the argument list rather than the status: a check that returns the right code
for the wrong reason fails now.
2026-08-10 13:38:26 +02:00
Maurice d95aa49c7f Split the trip aggregate root into its parts
TripsService injected fourteen other services. Almost all of them were there
for two read aggregates - the MCP trip summary and the offline bundle - and
for the member roster, not for anything the write path does. Reading is what
fans out; writing is not.

Membership goes to trip-members/: listMembers, addMember, removeMember,
transferOwnership and the four guest methods, plus the six routes, which keep
their paths. It is a NEW module, not the trip-membership/ leaf next door:
deleting a guest erases their plugin data and re-splits their expenses, so it
needs auth and budget, and AuthModule already imports that leaf. Giving it
those dependencies there would have closed a hard cycle. This one is a sink -
nothing imports it back except trips.

The read aggregates go to trip-read-model/, which is where the nine-service
fan-out now lives.

What is left is the write core plus day generation, on eight parameters
instead of fourteen: database, reservations, days, permissions, budget,
vacay, realtime, unsplash. The last four are all reachable from updateTrip's
date-shift transaction, which is why the count does not fall to three.

Trips still reaches budget through ReservationsModule, so trips.bridge.ts
survives this. Saying otherwise would have been the easy claim to make and it
would not have been true.
2026-08-10 13:38:26 +02:00
Maurice 3d522be0e5 Give accommodations one home instead of two halves
The routes lived in reservations/ while the SQL lived on DaysService, so the
controller delegated every call across a domain boundary to reach its own
data, and neither module owned the thing outright. The seven statements move
into AccommodationsService unchanged and the domain gets its own directory,
module, DTO, RPC and MCP surface.

What stays in days/ is the reorder side: assertNoInvertedAccommodation and
resyncAccommodationDays are about re-dating day rows, and the stay is the
thing being carried, not the thing doing the carrying.

Two edges fall out of this. AccommodationsService drops its DaysService
dependency, and DaysMcp drops DatabaseService and PlacesService, because
create_place_accommodation was their only user - which lets DaysModule stop
importing PlacesModule and closes one leg of the latent
Days -> Places -> Assignments -> Days cycle.

DAY-SVC-016..026, 029 and 035 move to accommodations.service.test.ts with
their case ids intact. The reservations e2e container assembles both modules
now: it builds a single domain module on purpose, which is exactly what
catches a move like this, and it caught it.
2026-08-10 13:38:26 +02:00
Maurice ad312cdd43 Give day notes a domain of their own
days/ carried two fachlichkeiten with a full file set each: days.controller,
days.service, days.mcp, days.rpc, days.dto next to the five day-notes ones.
The note files move out unchanged; because the new directory sits at the same
depth, not one import path inside them changed.

The two modules deliberately do not reference each other in either direction:
dayExists is raw SQL on the days table in the note service, and DaysService
reads day_notes the same way, so a module edge would buy nothing and cost a
cycle.

DayNotesModule has to be registered in PluginsRuntimeModule as well as
AppModule - DayNotesRpc owns four of the 113 wire methods and the registry
boots with requireTotalCoverage, so leaving it out would stop every test app
that assembles the plugin runtime.

The days e2e container assembles both modules now: the note routes still nest
under the days prefix, and a container that builds only DaysModule would 404
on them while production served them fine.
2026-08-10 13:38:26 +02:00
Maurice 0d8870cff7 Stop two test suites from depending on the network and the scheduler
The second flake recorded as 'costs' is not a timing flake. It is a live
HTTPS call: budget.service.ts settlement() reaches getRates()
unconditionally, unlike the two guarded call sites above it, and
ExchangeRatesService fetches api.frankfurter.dev with a 10 s abort.
tests/integration/budget.test.ts stubbed fetch nowhere at all, so the suite
talked to the internet - fast when the API answers, ten seconds per call
when it does not. The stub fails closed, which changes no assertion: every
settlement case here is single-currency, so rates never enter the
arithmetic. tools-budget-advanced had the stub inside a single case; hoisted
so a second settlement case cannot quietly reintroduce the call.

The five ReDoS budgets in maps.service.test.ts measured wall-clock time,
which on a loaded machine reports how long the test waited for a core.
MAPS-024 has gone red at 523 ms against an unchanged file for exactly that
reason. They now measure CPU time, which is what catastrophic backtracking
actually burns. This does not soften them: CPU time is always <= wall-clock
time, so every budget got stricter, and pool:'forks' keeps the measurement
to this file.
2026-08-10 13:38:26 +02:00
Maurice 7f5c537ad6 Hand the feeds calendar parts instead of a document to re-parse
The all-trips feed merges many trips into one calendar. It did that by
generating each trip's finished ICS and then scanning the text back apart:
one pass for the VEVENTs, one for the VTIMEZONEs, plus a string replace to
splice the refresh hints into the header. The scans had to be structural
rather than regexes, because a user-supplied SUMMARY can legitimately
contain the literal 'END:VEVENT' and a lazy match would truncate the event
there.

CalendarService now returns the parts - calendar name, filename, VTIMEZONE
blocks keyed by TZID, and the VEVENT blocks - and exportICS serialises them.
Both scanners are gone.

CAL-010 pins the whole serialised document as a snapshot, with DTSTAMP and
the UID counter normalised so it pins the calendar rather than the minute it
ran in. It is what proves this changed no bytes: it was taken before the
model went in and still matches.

Two tests still pointed at the health controller deleted in d37af204:
health.di.test.ts loses its only subject, and the AppModule wiring test now
resolves FeaturesController instead - it is the one test that builds the
whole container, so it should resolve something that still exists.
2026-08-10 13:38:26 +02:00
Maurice 5a9ded6676 Move the calendar export off the trip aggregate
exportICS and its four module helpers were 330 lines of RFC 5545 inside
TripsService, and FeedsService had to import the whole trip aggregate to get
an ICS string out of it. They move to a calendar domain unchanged - same
statements, same escaping, same folding, same VTIMEZONE fallback - so the
emitted bytes are identical on both the download and the feed.

The 21 unit cases move with the code and keep every assertion, which is what
makes that claim checkable rather than asserted. FeedsModule now imports
CalendarModule instead of TripsModule, and its e2e container drops from the
trip aggregate to two small modules.

NotFoundError/ValidationError move to common/ so a second domain can raise
them without importing the first one for its error types; trips re-exports
them, so the nine files that import them from there are untouched.

Fixes a harness that tsc cannot see on the way past: mcp-test-controllers
built TripsService with 12 arguments for 14 parameters, leaving unsplash and
userCleanup undefined.
2026-08-10 13:38:26 +02:00
Maurice cc9587676a Give the feature-flag endpoint a health module of its own
GET /api/health/features lived in booking-import, which meant the client's
capability probe was reachable only because that domain happened to be
registered. Move it to health/ and give health an actual module instead of
two loose entries in app.module.

The extractor probe follows into a leaf module rather than health importing
BookingImportModule: features() needs isAvailable(), not llm-parse,
reservations, budget, maps and places.

The /api/_nest/{health,me,echo} smoke endpoints go. They proved boot, DI,
the cookie guard and the Zod pipe back when nothing else did; every one of
those now has real domain coverage, and nothing outside their own test ever
called them. The container probe is GET /api/health on the raw express layer
and is untouched.
2026-08-10 13:38:26 +02:00
Maurice 36cdb36875 Keep the pictures as a grid, wider tiles instead of a lead image 2026-08-10 12:24:15 +02:00
Maurice 46318db5ac Give the detail column a lead image and pin its one action 2026-08-10 12:24:15 +02:00
Maurice ad6640cdb2 Fill the gaps Google leaves from the free sources 2026-08-10 12:24:15 +02:00
Maurice 0cd67675c5 Describe the chain when nothing describes the branch 2026-08-10 12:24:15 +02:00
Maurice 92e4eaa476 Move the enrichment switch to the domain that owns the places flags 2026-08-10 12:24:15 +02:00
Maurice e3b220603c Point at the Google key only where its absence is the reason 2026-08-10 12:24:15 +02:00
Maurice e321bfcf1b Match a place name across its inflected forms, and forget an empty answer quickly 2026-08-10 12:24:15 +02:00
Maurice e1a52ba43c Stop a picked place leaving its website on the next one 2026-08-10 12:24:15 +02:00
Maurice 880e258438 Show a week of opening hours as a week 2026-08-10 12:24:15 +02:00
Maurice 0c8788ce44 Give a Google place its free half back 2026-08-10 12:24:15 +02:00
Maurice 8f9af76204 Ask the sources that know the place before the ones that know the area 2026-08-10 12:24:15 +02:00
Maurice 14d6f2ecc9 Keep the hover card's corners round without its tail 2026-08-10 12:24:15 +02:00
Maurice 2a94a5eb8e Put the add-place dialog on the theme tokens 2026-08-10 12:24:15 +02:00
Maurice e03b6a3080 Match the keyboard button to the date field beside it 2026-08-10 12:24:15 +02:00
Maurice 0d58080fa6 Lay the trip form out in two columns and give it the dashboard's shapes 2026-08-10 12:24:15 +02:00
Maurice a2a9c3094c Drop the tail from the map hover card 2026-08-10 12:24:15 +02:00
Maurice f5a483da64 Get the pictures right and stop reusing stale answers
Three things you could see on dev1.

Pictures were "roughly right" rather than right, because a coordinate search
only knows what was photographed within 300 m. Wikidata's P18 is one picture
somebody chose to represent that exact object - "Zurletzteninstanz.jpg" for the
restaurant, not a statue down the road - so it now leads, then the Commons
category, and geosearch is the last resort it should always have been. Small
venues carry a wikidata tag far more often than a Commons category, so this is
also what reaches the places the other sources miss.

Descriptions stayed on Wikipedia after the switch to Wikivoyage because the
week-long result cache predates it. It carries a format version now; a release
that changes the shape or the sources invalidates its own entries instead of
serving last week's for another six days.

Ratings and opening hours were being fetched from Google and then dropped on the
floor - the fact list only looked at OpenStreetMap tags. Both are shown now,
whichever provider supplied them.

Also: the column follows a place opened from the map, not only one picked from
search. A POI tap arrives as prefillCoords and never touched the selection, so
the column kept showing the previously added place.

Pictures are a tight three-column grid and only the picture in play is credited
in full, on one line. Crediting all of them at two lines each drowned the
column, and the licence attaches to the one that gets used - whose credit also
stays visible in the inspector after saving.

The request carries the place record the dialog already fetched, so the server
skips a details lookup it was making in parallel with the client's own. That
lookup was measured at 12.8 s for a large OSM relation.
2026-08-10 12:24:15 +02:00
Maurice d98d597581 Make the detail column useful for places no encyclopaedia describes
A restaurant got nothing: no wiki article, and a coordinate search around it
returns whatever was photographed nearby rather than the place itself. Three
changes, all on free sources:

OpenStreetMap facts. A tagged restaurant carries its cuisine, its opening hours,
a link to its menu, outdoor seating, step-free access and diet tags. Those
arrive with the details lookup the dialog already makes, so showing them as
chips costs nothing and is the single biggest gain for exactly the places that
were empty before. Only positive facts are listed - "no outdoor seating" is
noise.

Commons categories. Where a place has a wikimedia_commons tag, its category is
pictures OF it; geosearch falls back in when the category is empty.

Wikivoyage before Wikipedia. Same API, same licence, but it describes a place
for someone about to visit rather than opening with area in square kilometres.
Two sentences instead of three, because this sits beside a form.

The column also follows the section-heading idiom Vacay and the dashboard share
(uppercase overline, wide tracking), its header matches the Saved places panel
beside it, and a lead picture with a row underneath replaces the two-column
grid that left a hole on every odd count. Credits wrap over two lines instead
of truncating - a cut-off name credits nobody.

Saved places stops after ten with a Show more button; search and filters still
run over everything, not just the visible page.

Form spacing is one rhythm (12px between groups, 8px inside, 4px label to
field) and Notes matches Description in height. The OSM notice fits on one line
and no longer claims there are no photos, which stopped being true when the
column started fetching them from Wikimedia. The clipboard hint is gone; paste
still works.
2026-08-10 12:24:15 +02:00
Maurice fc9ac64496 Rebuild the enrichment cache when its pictures were swept
The nightly photo sweep deletes every candidate nobody picked, which is most of
them. The cached result still pointed at those files, and the read filtered them
out - so the morning after, a place with perfectly available pictures showed an
empty column, and kept showing one for the rest of the week the entry lives.

A missing candidate now invalidates the whole entry instead of shrinking it.
2026-08-10 12:24:15 +02:00
Maurice 1f1ed630fc Fetch the enrichment pictures concurrently
The column showed "details could not be loaded" for every place. The endpoint
was answering correctly, just after ten seconds, and the shared axios instance
gives up at eight.

The downloads sat in a loop: five Commons hits meant five round trips and five
decodes in sequence, even though the slot limiter already allows five at once.
Both provider listings now run together and the bytes are fetched concurrently,
which brings a warm request back to about a second.

Candidate keys move with it. They now number the provider's list rather than the
surviving strip, so a picture that fails to download leaves a gap instead of
renumbering the ones behind it - a key has to point at the same file on the next
request or the cached result goes stale against the photo cache.

The client asks for enrichment with a longer timeout, and the Wikimedia calls
carry one of their own: a cold TLS handshake from a fresh container was measured
at eight seconds on its own, and no pictures is a better answer than a request
that never comes back.
2026-08-10 12:24:15 +02:00
Maurice b36103d2a9 Document where place pictures and descriptions come from
NOTICE knew about geoBoundaries, OSM and OurAirports but not about Wikimedia,
Wikipedia or Google Places, all of which TREK has been calling for a while.

Also fixes two dead paths in the place contract - it still pointed at
server/src/routes/places.ts and services/placeService.ts, both long gone.
2026-08-10 12:24:15 +02:00
Maurice 5d99886b04 Keep a picked place image and its credit visible everywhere
Accommodation cards read the place from page-local state, so a new thumbnail
only showed up there after a reload. place:updated now asks the planner to
reload them, but only when the image actually changed - renaming a place must
not trigger a refetch.

The inspector shows the stored credit under the avatar. Crediting a CC BY-SA
picture only in the moment it is chosen is not enough; the obligation travels
with the picture.

Also lets a GIF hero through to the PDF. Uploads accept gif, the PDF extension
check did not, so one silently fell back to the category icon.
2026-08-10 12:24:15 +02:00
Maurice 4843ebc937 Show place details beside the search field
Picking a search result fills a column on the left of the add-place dialog -
pictures and a description, no extra click. Someone who already knows the place
ignores it and saves exactly as before.

The column stays mounted with an empty state instead of appearing on the first
pick, otherwise the dialog jumps sideways mid-typing. Requests are cached in
memory and in sessionStorage and aborted when the selection changes, so clicking
back and forth between two results does not pay twice.

Every thumbnail carries its author, its licence and a link to the file page.
That is not decoration - most of these are CC BY-SA, and reusing one without
naming the author does not satisfy the licence. The description is shown but
never written into the place on its own; a button copies it across, and it is
disabled while the user already has a description of their own.

Desktop only. The mobile sheet is untouched.
2026-08-10 12:24:15 +02:00
Maurice f2368f86cf Add an admin switch for place enrichment
Reads fail-open, unlike the three older places_* switches. Those read
=== 'true', which is why migration 185 had to backfill a row on every existing
install; there is nothing here that warrants repeating that.

The three hand-rolled toggles next to it move to ToggleSwitch while I am in
there - they were bare buttons with an inline transform and no aria-pressed or
label, so a screen reader could not tell their state.
2026-08-10 12:24:15 +02:00
Maurice e9f9cfb5d5 Add the place enrichment endpoint
Fans the new provider primitives out into one answer for a place that is being
looked at but not saved yet: a strip of pictures and a description, each with
its source.

Free sources are the normal case - most instances have no Google key at all, so
Commons and the OSM tags have to carry this on their own and Google is what an
admin adds on top. Pictures are copied into the existing photo cache under
"<placeId>~pN" keys rather than hotlinked: Google's photo URLs expire (migration
107 had to repair exactly that once), and a provider URL in the strip would send
every viewer's address to Google or Wikimedia. Whatever nobody picks is
unreferenced and the nightly sweep clears it out.

The result is cached for a week in place_details_cache under a third expanded
value, so no migration. Rate limited per user, and a provider being down answers
empty rather than failing the dialog.
2026-08-10 12:24:15 +02:00
Maurice 23e76cc3ec Let the maps providers hand back candidates, not just one picture
Commons geosearch already returned five files and we threw four away, so a
whole strip costs nothing extra. It now comes back with the licence metadata
too: without LicenseShortName and the file description page there is no way to
credit a CC BY-SA image, and a picker full of them needs that.

Wikipedia descriptions resolve through the OSM wikipedia tag rather than
guessing the article from the place name, which lands on the wrong article for
anything ambiguous. Google photo references and photo bytes are separate calls
now so a caller can list without downloading, and the editorial summary has its
own lean field mask - asking for it through the expanded details would drag
reviews along and move the call into the pricier tier.
2026-08-10 12:24:15 +02:00
Maurice 4cd50e6ed3 Add a contract for place enrichment 2026-08-10 12:24:15 +02:00
Maurice c674df72a2 Keep the atlas controller test free of the MCP SDK chain
Asserting the module registration meant importing AtlasModule, which pulls
AtlasMcp and the whole MCP SDK behind it into a file that otherwise
constructs a controller with a stub. It does not resolve when the file runs
on its own.

PROFILE-014 already covers the registration, and covers it better: it calls
GET /api/auth/travel-stats through the full app.
2026-08-09 22:43:05 +02:00
Maurice accd806786 Give the travel-stats cases a file of their own
atlas.service.test.ts carries the admin1 GeoJSON bundle warm-up from #48, and
its ATLAS-UNIT-021..024 cases sit close to the 15s limit whenever the machine
is busy. Eighteen more cases in the same file is not what they need.

Same DB-per-file pattern, no shared state, case IDs unchanged.
2026-08-09 22:43:05 +02:00
Maurice 9895c7112a Give the admin OAuth-session panel back to the oauth domain
The last of AdminService's borrowed SQL. oauth_tokens belongs to oauth/,
which already had a user-facing view of the same table; AdminService carried
a second one only because the panel route is under /api/admin.

Both survive, under names that say which is which. They are not duplicates:
the user-facing listOAuthSessions is scoped to one user, the admin one spans
all of them and joins the owner username, and the admin revoke kills the
client session for whoever owned the token.

OauthModule gains its first export. Its header said nothing inside the
container injects the domain, which stops being true here.
2026-08-09 22:43:05 +02:00
Maurice 93bbc65520 Return the registration invites to the domain that consumes them
invite_tokens gates signup. Its consumer is the registration path in
AuthService, not anything in admin/, which held the four management methods
only because the routes sit under /api/admin.

Worth spelling out because the names invite it: invite_tokens and
trip_invite_tokens are different tables for different jobs, and this one has
nothing to do with adding a member to a trip. An earlier reading of the plan
had these going to trip-invite/, which would have been the wrong home.

The routes keep their paths and their admin guard; AdminController injects
the service instead of carrying another domain's SQL.

Also recorded, in the file, why the app-settings pair did NOT move to
settings/ with the other app_settings readers: updateAppSettings runs the
lockout guard that stops an admin disabling every login method, and that
guard is auth's own resolveAuthToggles. Moving it would put an AuthModule
import inside a leaf module half the container pulls in.
2026-08-09 22:43:05 +02:00
Maurice 45611e2514 Separate the profile a user administers from the identity they log in with
The second cut into AuthService. Display settings, avatar, the third-party
API keys a user pastes in and the member directory are not identity: no
password is checked, no session is issued, no MFA secret is touched.

UserProfileService takes those eight methods verbatim, with DatabaseService
as its only injected dependency. AuthService keeps register, login, JWT,
MFA, password reset and account deletion, and is now roughly 900 lines
lighter than it started this branch.

Not exported from AuthModule: only its own controller needs it, and an
export nobody imports is noise.

The controller test harness builds AuthController positionally, so the two
new parameters landed in optional slots and the stubs for the moved methods
moved with them.
2026-08-09 22:43:05 +02:00
Maurice 03fbced20b Move tokens out of AuthService into a domain of their own
AuthService carried identity, profile, settings and tokens at once. Tokens
are the cleanest of those to cut loose: they touch one table plus the
ephemeral-token store, and nothing in them needs to know how a password is
hashed or how a session is established.

TokenService takes the six user-facing methods plus verifyMcpToken, and the
two admin-side ones that only lived on AdminService because the admin route
did. The pairs are genuinely different queries rather than duplicates, so
both survive under names that say which is which: the user-facing ones scope
every statement by user_id and treat the session sweep as best-effort, the
admin ones do neither.

Left behind on purpose: verifyJwtToken, which is login identity and belongs
next to the cookie logic, and isDemoUser, which is a demo gate that happens
to read the users table.

TokensModule is a leaf with nothing but DatabaseService, because auth, admin
and the MCP transport all import it and anything added here lands in three
graphs at once.

Both bridges and both controller test harnesses construct these classes
positionally and had to be pulled along; the new argument goes in an
optional slot so untouched call sites stay untouched.
2026-08-09 22:43:05 +02:00
Maurice e8d4278899 Give travel stats to the domain whose data it reads
getTravelStats sat on AuthService for historical reasons only. It reads
places, trips, visited_countries, place_regions and reservation_endpoints,
and subtracts the countries hidden in Atlas, so every one of its sources is
an Atlas concern.

Keeping it there had a real cost: AuthModule had to import AtlasModule, and
that edge is the reason atlas.mcp.ts reaches AuthService through auth.bridge
instead of injecting it. The direction is now free to reverse.

GET /api/auth/travel-stats keeps its path. The client calls it there, so the
route moves to a small controller in atlas/ that holds the /api/auth prefix
rather than changing the URL. PROFILE-014 runs against the full app and
proves the endpoint is untouched.

KNOWN_COUNTRIES moves along, since getTravelStats was its only reader. Both
bridges construct AuthService by hand and needed the argument dropped.
2026-08-09 22:43:05 +02:00
Maurice 8a09f55571 Give three more admin surfaces back to the domains that own them
default-user-settings, notification-preferences and oidc leave AdminController.
The first two were pure pass-throughs on AdminService that did nothing but
forward and cast; the third had its SQL there for a domain that already had a
module of its own. AdminService loses SettingsService and
NotificationPreferencesService entirely.

Two things came out of the move rather than going into it.

The OIDC lockout guard now has tests. It refuses to clear the SSO config while
password login is disabled — otherwise the instance has no SSO to log in through
and no password form either — and nothing covered that branch before, because it
sat among admin tests that only exercised the happy path.

The admin e2e app had to learn about the split. It assembles AdminModule alone,
so the moved routes 404'd there while working in production; it now imports the
three domains too. That is the same failure mode the module-provider guards
catch for RPC classes, and it is worth knowing that an e2e app which builds a
subset will find it first.

Also fixes the audit route note: audit-log stays on AdminController on purpose.
AuthModule imports AuditModule, so an admin-gated controller in AuditModule
closes a cycle — the same trap the addons toggles hit.
2026-08-09 20:54:39 +02:00
Maurice d4a1601855 Give the packing templates and the feature flags back to their domains
Twelve admin packing-template routes sat on AdminController and reached
PackingService through eleven pass-through methods on AdminService that did
nothing but forward and cast. PackingService already owns all three template
tables, so the routes move to AdminPackingTemplatesController next to it and the
pass-through block is gone. AdminService no longer injects PackingService at all.

The three places flags were raw app_settings SQL on AdminService, sitting beside
bag-tracking and collab-features which it already delegated to AddonsService.
They move to AddonsService, which owns every other flag, and AdminController now
calls it directly rather than through delegating methods.

The toggle ROUTES stay on AdminController, which is a departure from the plan and
worth stating: an admin-gated controller in AddonsModule makes that module import
AuthModule, and PluginGuardsModule imports AddonsModule precisely because it is a
leaf — Nest refused to build the graph. Same for the addon CRUD, whose PUT
cascades into PluginRuntimeService. Both notes are in the code.

Paths, response shapes, the {error,status} envelope, the create-201 split and
every audit action are unchanged.
2026-08-09 20:54:39 +02:00
Maurice 94221a6d91 Roll TripAccessGuard onto the remaining trip-scoped controllers
assignments, budget, collab, accommodations, reservations and booking-import
drop their copied requireTrip/requireEdit helpers for the guard. Status codes and
bodies are unchanged.

places is deliberately NOT converted, and that is the finding of this PR rather
than an omission. Its e2e suite documents that the body pipe answers 400 ahead of
the trip 404 — a parity shift somebody made on purpose — and a Nest guard always
runs before the pipe, so converting it silently reverses that ordering. The first
attempt also dropped the permission check on DELETE, which the integration suite
caught as a 200 where a 403 belonged. places keeps its in-handler check, and
README now says why so the next person does not rediscover it the same way.

collab applies the guard per handler for the same reason files does: its note-file
upload route would otherwise be refused before multer had read the body, and a
response sent mid-upload destroys the socket.

Two coverage notes. The per-domain thresholds are regenerated because deleting a
well-covered helper lowers a file's ratio without anything becoming less tested —
the drops are 0.4 to 1.2 points across six domains and match the helpers removed.
And the atlas integration suite gets the same admin1-bundle warm-up the unit suite
got: ATLAS-013 was paying 3.1s for the one-off decompression inside its own 5s
budget, which is why it only ever failed under load. It now runs in 116ms.
2026-08-09 19:46:00 +02:00
Maurice 8c0280e287 Replace the copied requireTrip helpers with a TripAccessGuard
Every trip-scoped controller carried its own three-line requireTrip, and a
requireEdit next to it: eighteen copies of a security check, each of which had to
remember that no access is a 404 'Trip not found' and never a 403 — the
distinction that stops a stranger learning which trip ids exist. One of them
getting it wrong would have looked like nothing.

TripAccessGuard resolves :tripId once, refuses what the user cannot reach and
hands the row to the handler through a @Trip() param decorator (a param
decorator rather than a req.trip cast, because the request type that used to be
widened for that is gone). @RequirePermission carries the same action string the
domain services pass to checkPermission, so the HTTP and MCP paths cannot drift
apart on which right they demand. Status codes and bodies are unchanged.

Piloted on days, day-notes, todo, packing and files. Two things worth knowing:

Files applies the guard PER HANDLER and leaves the upload route on its own
check. Guards run before interceptors, so a class-level guard would answer 404
before multer had read the multipart body, and a response sent while the client
is still streaming gets the socket destroyed — the caller sees ECONNRESET
instead of the 404. Refusing after the body has been read costs the bandwidth
and keeps the answer readable.

The services keep verifyTripAccess and canEdit: the large majority of their
callers are *.mcp.ts tools, which never pass through an HTTP guard. canEdit lost
its incidental controller-test coverage in the move, so it now has direct tests
asserting the action and the shared flag — the same pair the guard reproduces.

Also fixes the backup zip-slip test that failed only on CI: it asserted a
drive-letter path, which is absolute on win32 and an ordinary relative segment on
Linux, so the branch it was guarding never fired there.
2026-08-09 19:13:21 +02:00
Maurice fef338091c Fold backupService into the backup domain
src/services/backupService.ts moves to src/nest/backup/backup.impl.ts, imported
only by its own domain now that the controller reaches it through BackupService.
src/services/ is down to airtrail/.

It stays a module of free functions rather than becoming methods on the service.
The restore path closes and reinitializes the core DB handle, which is the single
most dangerous sequence in the server, and rewriting its shape in the same step
as the move would make a regression there impossible to bisect. The db-lifecycle
question is the last step of this fold, not part of it.

The move also pulled 512 lines out of ungated src/services/ into a gated domain,
which is how the per-domain ratchet caught that the per-entry extraction loop had
no tests at all: the zip-slip refusal, the running decompressed-byte cap (the
declared size in the central directory is attacker-controlled, so only the
running total catches a lying archive), a corrupt stream not being dressed up as
a size refusal, and the reopen failure that has to report restart-required rather
than success. Six cases now cover it, and backup goes from 84% to 96% statements.
2026-08-09 18:44:59 +02:00
Maurice 6680bc7c74 Split PluginsModule into four, and gate coverage per domain
PluginsModule was 21 controllers, 24 domain imports and 10 providers in one
class, which made it the hub most of the Nest graph ran through: AdminModule
imported it for a single cascade-disable call and inherited all of it. It is now
PluginsRuntimeModule (supervisor, capability router, hook contracts),
PluginContributionsModule (the 14 read-only hook controllers, moved to
contributions/), PluginOAuthModule (the credential leaf, moved to oauth/) and a
composition root that keeps the install and delivery controllers. AdminModule
imports the runtime alone.

The three plugin files that reached for the db singleton alongside the injected
DatabaseService no longer do. plugin-host-state and signature-status take the
connection from the caller, and the three per-user settings functions that were
bare exports 'so the RPC host wiring can call them directly' are now
PluginUserSettingsService - the host wiring is providers, so it injects them.

Coverage moves from one repo-wide 80% to a per-domain ratchet. The single number
let a well-covered domain subsidise a thin one: booking-import sits at 50% and
integrations at 20%, and the aggregate still cleared the bar, so either could
have lost another ten points unnoticed. Each domain now carries its own measured
floor, regenerated by scripts/coverage-thresholds.mjs.

Two test-side findings on the way:

expect(undefined).toContain(SomeClass) PASSES in this vitest version - it only
throws for a string needle. Every '<X> is listed in its module providers' guard
was written that way, so all twelve of them silently stopped guarding the moment
their class moved. They go through expectRegisteredProvider now, which asserts
the array shape first.

The atlas region test paid for the one-off decompression of the multi-MB admin1
bundle inside its own 5s budget, which is why it failed under load and passed
alone. The bundle is warmed in beforeAll instead.
2026-08-09 18:44:59 +02:00
Maurice dc81a5365e Make PluginsModule bring the whole plugin surface with it
With requireTotalCoverage on, a test app that assembles a subset of AppModule
now fails to boot: the admin e2e suite pulls PluginsModule in through
AdminModule, but WeatherModule was only ever reachable from AppModule, so
weather.get had no handler and validate() refused the start.

The invariant the gate needs is that importing PluginsModule pulls in every
domain that owns part of the wire surface. WeatherModule was the one domain
missing from that list.
2026-08-09 17:38:56 +02:00
Maurice f72aba0c91 Finish the plugin RPC decorator rollout
The last 20 wire methods move onto the decorators: db.* and the three
unconditional methods (DbRpc), meta.* (MetaRpc) and the host-mediated surface
of users/broadcasts/notify/ai/oauth/scheduler (HostSurfaceRpc). That closes the
ledger at 113/113, so the legacy has() blocks in rpc-host.ts are gone and the
router is down to what was never domain-specific: build the granted subset of
the dispatch map, dispatch, audit, map errors.

PluginHostDepsFactory was the 26-argument wiring sheet behind those blocks. With
the handlers in the domains it has two dependencies left, so it is now
PluginRpcHostFactory: the plugin's own sqlite handle, the inter-plugin router
bound with this plugin as the caller, and the audit sink.

The 18 host-to-plugin hook calls get the same treatment. They used to spell out
the fn name and the timeout at each controller, which meant nothing checked that
a hook:* grant on the consent screen had a consumer behind it, that the fn
matched what the SDK documents, or that two call sites agreed on the budget.
PluginHooks declares all 18 with @PluginHook and performs them, and the
controllers call it by name.

With both halves complete, requireTotalCoverage goes on: a KNOWN_METHOD without
a handler, or a hook without a consumer, now fails app boot instead of surfacing
as a PERMISSION_DENIED nobody can explain.
2026-08-09 17:38:56 +02:00
Maurice 611f94e820 Consolidate the schema-error fallback and cover the defensive branches
Aiming the new files at 95% rather than at the 80% gate turned up something worth
fixing rather than testing around: `error.issues[0]?.message ?? 'bad input'` had
been copied into twenty handlers. That is twenty two-branch fallbacks that no test
can reach, because a real Zod failure always carries at least one issue. They are
now one schemaMessage() in rpc-params.ts, with one test that does reach both sides.

The rest of this is the branches the handlers inherited from the router: the
`!= null` link ternaries, the "service said no" paths, the ValidationError and
NotFoundError translations, and a reject case per schema-validated method.

Two limits found while measuring, both real rather than laziness. istanbul counts
each @PluginController line as a branch and only ever takes one side, so eight
branches across the rpc files can never be covered and a two-method file caps out
around 50%. And the three update schemas stay deliberately permissive, because the
migration's rule is parity over tightening, so their reject branch is defence in
depth no payload reaches. Everything else went from 50 uncovered branches to 29.
2026-08-09 16:38:43 +02:00
Maurice 294cfcd8d7 Move the addon-gated domains onto the decorators
collab, journal, atlas, vacay and collections: 32 more methods, so the registry
owns 93 of 113. rpc-host.ts is at 565 lines, down from 1332, and the deps factory
at 384, down from 944.

These five are the risky group, because their addon gates lived inside the deps
factory's closure rather than in the router. A handler that calls its service
directly still works, just without the check, and neither the compiler nor the
router tests would notice. So addon-gates.rpc.test.ts walks all 37 gated methods as
one table and asserts two things for each: with the addon off it is refused with
that addon's message, and the service is never reached. The table length is pinned,
so a later method added without a row is a failure rather than a silent gap.

One ordering detail worth knowing: on the atlas writes the router evaluated its
params before the addon gate inside the closure, so a malformed country code still
reports BAD_PARAMS rather than the addon. The handlers keep that order.

The collections service reports refusals as status-tagged errors rather than typed
ones, so mapCollectionError came across with the handlers: 403 and 404 become
RESOURCE_FORBIDDEN, 400 and 409 become BAD_PARAMS, and anything untagged stays a
HOST_ERROR instead of being dressed up as a permission problem.
2026-08-09 16:38:43 +02:00
Maurice 7bca741f28 Cover the fallback branches the moved handlers brought with them
The branch threshold over src/nest/** dipped to 79.89% after the move: the new
*.rpc.ts files carry the defensive branches the router closures had (?? '',
|| current, optional cascades), and none of them were exercised.

Adds the cases that reach them: a booking whose cascade does not fire, an update
that keeps the stored title, a bag colour of the wrong type, a non-array userIds,
a missing bag across all three bag writes, and the block delete without a partner
reservation. Also corrects two assumptions I had written into the booking tests:
title and type are required by the shared schema, so the handler's own fallbacks
sit behind it as defence in depth rather than as the first line.
2026-08-09 16:38:43 +02:00
Maurice 010242b107 Move costs, reservations and accommodations onto the decorators
Eleven more methods, so the registry owns 61 of 113. rpc-host.ts is at 771 lines,
down from 1332.

costs is the first addon-gated domain, and the addon check sits in a different
place in each of the two reads: inside the membership callback for getByTrip, so a
caller without trip access never learns whether the addon is on, and before
everything for listMine, which has no trip to check. Both orderings are asserted.
The writes keep their own refusal wording ("no permission to edit costs on trip N")
instead of going through requireTripEdit, which words it differently.

The booking paths are cascade-heavy and stay that way: creating a reservation can
create an accommodation and a budget item, deleting one removes both again, and
deleting a lodging block takes its partner reservation and budget item with it.
Each cascade is a broadcast the web app relies on, so they are asserted by event
name and order.

CostsRpc reaches the trip list through trips.bridge rather than by injection.
BudgetModule cannot import TripsModule, because TripsModule already imports
BudgetModule; budget.mcp.ts uses the same escape for the same reason.
2026-08-09 16:38:43 +02:00
Maurice 2ec6d006b0 Move the trip surface onto the decorators
Twelve more methods, so the registry owns 50 of 113. rpc-host.ts is down from 1332
lines to 830 and the deps factory from 944 to 500.

trips.update keeps its two field-level gates: archiving and changing the cover each
need their own admin-configurable permission on top of trip_edit, so a plugin
cannot do either on a trip it may otherwise edit. Member management keeps its own
grant for the same kind of reason, since adding a member grants trip access, and
the owner still cannot be removed through it because that would orphan the trip.

canEditTrip stays in HostDeps for now: the db:meta gate still resolves an entity's
edit permission through it and has not moved yet.
2026-08-09 14:13:56 +02:00
Maurice c326d39a07 Move places, days and itinerary onto the decorators
Eight more methods, so the registry owns 38 of 113 and rpc-host.ts is under 1000
lines for the first time.

itinerary.* lives in assignments/, not in days/, because AssignmentsModule already
imports DaysModule and putting the handler the other way round would have closed a
cycle. It keeps riding on day_edit, which is what the app gates it on.

The delete path in places carries an ordering constraint that is silent when
broken, so it has two tests of its own: the journey hook has to run BEFORE the row
goes, because journey_entries.source_place_id is ON DELETE SET NULL and afterwards
there is nothing left to detach, and the place id has to be scoped to the trip
before the hook runs at all, because the hook keys on the place alone and would
otherwise detach another trip's entries even though the delete itself is refused.
2026-08-09 14:13:56 +02:00
Maurice d58f7aa399 Drop a dead expression from the files RPC test
Left over from writing the demo-mode case: a cast statement with no effect, which
eslint flags as no-unused-expressions. It is the one error in an otherwise
warnings-only run, so `eslint .` exits 1 and the Server Tests job fails on lint
before it runs a single test.
2026-08-09 13:24:58 +02:00
Maurice dbb0016cac Move the file surface onto the decorators
Six more methods, so the registry owns 30 of 113, and the deps factory is down to
660 lines from 944.

Files are the awkward corner of the plugin surface and stay that way on purpose.
Reading a file's bytes is a separate grant from reading its metadata, because a
passport scan is more sensitive than its filename, and the three writes use three
distinct rights instead of one domain action. FILES-RPC-006 pins that by watching
which permission each operation actually asks for.

This is also the only plugin path that touches disk, so the tests lean on it: the
extension blocklist, the size cap checked before the read rather than after, a
path that resolves outside the uploads root, and the demo-account upload refusal
that mirrors the REST guard.
2026-08-09 13:24:58 +02:00
Maurice ca88676858 Move packing onto the decorators and de-duplicate its privacy fan-out
Nine more methods, so the registry owns 24 of 113.

The #858 privacy logic existed three times: PackingService had the primitives, the
controller layered emitToViewers and broadcastUpdate on top privately, and the
plugin deps factory carried a standalone copy of all four, with a comment asking
for them to be kept in lockstep by hand. Migrating packing meant either copying
them a fourth time or fixing that, so the two private controller methods moved
into PackingService and the factory copy is gone. Controller and PackingRpc now
call the same code, and the controller suite proves it by passing with all 45
assertions unchanged.

The four public/private transitions get one test each, because each one is a way
to leak a private item to the whole trip room, plus a test that the privacy is
read before the write rather than after: reading it afterwards would report
"already private", skip the room delete, and leave the item on every member's
screen.
2026-08-09 13:24:58 +02:00
Maurice 62978aab5a Move five more domains onto the plugin RPC decorators
categories, weather, rates, todos and daynotes: 11 more methods, taking the
registry to 15 of 113. Handler bodies and messages are verbatim; the ledger test
tracks the split.

Two things the move surfaced rather than introduced. PluginGuards could not stay
in PluginsModule: that module imports 22 domain modules, so a domain importing it
back for the guards would close a hard cycle and the only way out would be
forwardRef. It now lives in a PluginGuardsModule leaf that imports nothing which
leads back, the same shape MailerModule got for AuthModule.

And the todo and daynote writes broadcast. Those events are not decoration, they
are what keeps an open session from showing stale data after a plugin write, so
they came across from the deps factory with the handlers, and there is a test
that a failed write broadcasts nothing.

Worth knowing for the next domains: todos are edited under 'packing_edit', not
under a todo-specific action, and TODO-RPC-003 pins that. Each new class also
asserts it is actually listed in its module providers, since a decorated class
nobody registers is invisible to discovery and answers PERMISSION_DENIED with no
other symptom.
2026-08-09 13:24:58 +02:00
Maurice 9b3d37ea29 Move the tags plugin surface onto the decorators
Wires the registry into the host and takes the first domain across, so the kit
lands with a worked example rather than as scaffolding nobody uses yet.

The registry is an additive, optional fourth constructor parameter on
PluginRpcHost, so all 107 existing construction sites keep compiling untouched;
that is what makes the rest of the rollout a per-domain move instead of a
big-bang one. bindInto runs LAST in the constructor, so a method still owned by
one of the has() blocks collides loudly instead of being silently overridden.

tags is the right pilot: four methods, a service that is already DI-native, and
it exercises the read/write permission split, the userless refusal and a
BadParams write. Handler bodies and messages are verbatim; only deps.foo(...)
became this.tags... and uid became ctx.actingUserId.

dispatch now strips the supervisor's _inv marker. The supervisor resolves the
acting user from it before dispatch and then passes the request on untouched, so
it has been visible to every handler and is in effect a reserved param name.
Nothing reads it: auditResource takes named fields only, and the one handler
that touches the params object as a whole (packing.setBagMembers) reads a named
field off it. Dropping it keeps a future strict parse from rejecting calls every
shipped plugin makes.

Two tests carry the rollout from here. rpc-coverage.test.ts derives both owner
sets from the code and proves they partition the method vocabulary exactly, so a
PR that moves a method and forgets to delete the legacy registration fails in
CI. And because a decorated class that no module registers is invisible to
discovery, with every call to it coming back PERMISSION_DENIED and no other
symptom, tags.rpc.test.ts asserts TagsRpc is actually listed in providers.
2026-08-09 11:58:56 +02:00
Maurice 6b6fbbd94c Lift the plugin RPC resource gates into an injectable
A decorated handler cannot reach the router's private tripRead / requireActor /
requireTripEdit, nor the canEditTripAs and requireAddon that live inside the
deps factory's closure. PluginGuards holds all of them so a *.rpc.ts class can
inject what it needs.

Every message is copied character for character, including the two asymmetries
that look like typos and are not: a trip read says "trip reads", while
requireActor appends the word "writes" to whatever noun it is given, and some
domains throw their own inline variant rather than calling it at all. The tests
pin the exact strings, not just the refusal, because shipped plugins read them.
2026-08-09 11:58:56 +02:00
Maurice 7b11680cc3 Add a decorator kit for the plugin RPC surface
The method-to-permission association exists twice today: declaratively in
METHOD_PERMISSION, and imperatively as 44 if (has(...)) blocks in rpc-host.ts,
with nothing asserting the two agree. protocol-paths.test.ts checks that each
method has some permission, never that it is the same one the router gates on,
and plugin-audit.ts inherits any drift silently.

@PluginMethod binds the two together. Its permission argument is typed as
MethodPermission<M>, so it resolves to the single literal the table assigns to
that method and a mismatch does not compile. validate() re-checks the same
argument against the same table at boot, because types evaporate under an as
cast. @PluginHook does the same against HOOK_PERMISSION.

UNCONDITIONAL_METHODS was missing from envelope.ts, so the three methods the
router registers outside every grant check had no vocabulary of their own.
Adding it here, with a compile-time proof that it stays disjoint from
KNOWN_METHODS: giving those three a METHOD_PERMISSION row would change which
methods isAuditable considers, and settings.get is deliberately the one
unconditional method that is not audited.

The kit is written to stay extractable into its own package, so an ESLint rule
pins what it may import. Note the rule is a deny list on purpose: these patterns
are gitignore-style, and a leading ** would exclude the parent directory of
every allowed path, which makes the ! re-includes silently ineffective.
2026-08-09 11:58:56 +02:00
Maurice 8c5a207a9b Make the AI notice say who is accountable 2026-08-09 11:41:05 +02:00
Maurice 12ff2d3228 Move the AI notice up and trim it 2026-08-09 11:41:05 +02:00
Maurice 6f76c691da Say in the README how we use AI 2026-08-09 11:41:05 +02:00
Maurice 7d0e90eb47 Make the plugin permission facts live in one place
The hook->permission map existed verbatim twice (the host supervisor and the
plugin SDK) and the permission list four times (envelope, SDK manifest, and
both admin panels in the client). All of them were in sync when measured -
63 entries each, 15 hook entries each - so this changes no behaviour. It
removes a quiet outage: a hook whose permission is added on only one side
fails silently in production, with the plugin installing, activating and then
simply never being called.

protocol/envelope.ts is the single source now. METHOD_PERMISSION was typed
Record<KnownMethod, string>, so its value side was unconstrained -
'db:read:trps' compiled and made the method unreachable forever. It is
`as const satisfies Record<KnownMethod, KnownPermission>` now, which catches a
typo'd value, a missing key and an extra key. The derived KnownPermission and
MethodPermission<M> are what the @PluginMethod decorator will check its
argument against.

Deliberately no explicit annotation on METHOD_PERMISSION: with one, typeof is
that Record, MethodPermission<M> widens to the whole union, and the check
silently evaporates.

server/scripts/gen-plugin-facts.ts imports envelope.ts and writes the SDK and
shared copies. It does not scrape. The guard it replaces pulled the block out
of plugin-supervisor.ts with a regex that goes null on a reformat, and it ran
only from prepublishOnly - drift would have surfaced as a broken npm release.

The generated files are checked in because publish-plugin-sdk builds from a
standalone checkout with no server present.

The SDK's re-exported types stay wide. plugin-sdk compiles under strict, and
grantGaps indexes HOOK_PERMISSION with a plain string, so an `as const` there
is TS7053 and would break the publish. Strictness belongs on the host side.

--check also fails on a permission with no admin.plugins.perm.* translation
and on one the CLI has no hint for. Both are silent today.

plugin-sdk/** was in no workflow path filter, so nothing in CI ever ran its
tests. It is in the filter now, alongside a job that runs the drift check.
2026-08-09 00:52:05 +02:00
Maurice f0f69df67e Fold the journey domain into Nest and finish the MCP migration
journeyService (1324 LOC, 36 exports) and journeyShareService (203) become
JourneyDomainService and JourneyShareService. Every SQL statement, error string
and broadcast is the one that shipped.

The two could never be split: the share service gates on isOwner, which lives in
the domain service, so it injects it.

JourneyDomainModule is a leaf carrying just the two services. Places,
assignments and the plugin host each need a handful of journey functions;
importing JourneyModule for that would drag MemoriesModule and both photo
providers into their graphs. Same shape as TrekPhotosModule.

The 23 tools in src/mcp/tools/journey.ts become JourneyMcp. src/mcp/tools/ is
down to _shared.ts and registerTools() no longer fans out to anything.

journey.bridge.ts keeps four exports, for the journey resources in
src/mcp/resources.ts that register at import time.

McpAccessMode is host-augmentable now. journey:share is neither read nor write,
so the three share tools would have had to be opaque predicates - and predicates
bypass the access policy entirely, invisible to the boot-time scope gate. A
scope typo in one would ship as a silently ungated tool. McpAccessModeRegistry
mirrors McpAccessGroupRegistry; unaugmented, mode stays 'read' | 'write'.

Fixes a latent bug in tests/helpers/mcp-test-controllers.ts: it constructed
PlacesService with five of eight arguments. tsconfig only includes src, so
nothing typechecked it, and the missing dependencies were harmless until the
journey skeleton hooks landed on the place write paths.

journey.mcp.ts arrived from src/mcp/ at 14.5% branch coverage - the legacy
registrar had no test file of its own. 40 tool tests take it to 93.4%, and 25
cases for the skeleton lifecycle, the photo gallery and the preference row take
the domain service from 73.9% to 82.8%.
2026-08-08 23:25:57 +02:00
Maurice 86e2a96eb6 fix(client): a deliberate logout should not book a return ticket
Pressing "log out" clears isAuthenticated while the page you were on is
still mounted, so ProtectedRoute re-renders for it and sends you to
/login?redirect=<that page>. Log out from the dashboard and the next login
goes to the dashboard - beating the startup destination every time after
the first, which is exactly how it looked: worked once, never again.

A session that ended on its own still gets the return ticket, because
coming back to where you were is the whole point of that parameter. A
deliberate sign-out is a fresh start and now gets none, so the startup
destination decides. The Navbar already hinted at this with its unused
noRedirect state; the flag lives in the store instead, since all seven
call sites of logout() need it, not just that one.
2026-08-08 23:01:40 +02:00
Maurice 795cc50dae fix(client): stop the startup redirect racing the settings request
RootRedirect waited for whoever else might call loadSettings, which on a
cold start meant waiting for a request queued behind everything else a
launch fires off. Traced it in the browser: the settings came back as the
388th request, long after the redirect had given up and gone to the
dashboard. That is what "it still just opens the dashboard" was.

It now asks for the settings itself - the store de-dupes, so this costs
nothing when a load is already in flight - and login spends its 2.6s
takeoff animation fetching them instead of leaving that time idle, so by
the time it hands over they are already there.

Also folds the five copies of setShowTakeoff + navigate-after-2600ms into
one takeOff(), since all of them needed the same fix.
2026-08-08 23:01:40 +02:00
Maurice e7851e79ff fix(client): honour the start page on a device that never mirrored it
The mirror answered "dashboard" for a browser that had simply never been
told, and RootRedirect took that as the user's choice - so the setting was
ignored on every device except the one that set it, and on that one too
after a logout cleared the mirror. The setting itself was stored correctly
the whole time, which is what made it look like nothing happened.

readStartDestination now says "unknown" rather than guessing, and a device
in that state waits for the real settings instead. That costs one wait,
once per device; afterwards the mirror answers and the launch is as fast
as before. Bounded by a timeout, because loadSettings deliberately leaves
isLoaded false on a failed request so it can retry - without the floor, a
launch with no backend would sit on the spinner forever.
2026-08-08 23:01:40 +02:00
Maurice 0a824cec84 docs(wiki): document the startup destination and the tab deep link
Includes the tab ids the ?tab= parameter takes - they are the historic
German names, which is not something anyone would guess. That matters for
the people who asked: a shortcut or a browser wrapper can point at a tab
without the setting at all.
2026-08-08 23:01:40 +02:00
Maurice 6bf2e0708d feat(client): add the Startup section to General settings
Desktop and mobile both get it, in the style of the file they live in -
the On/Off button pairs on desktop, segments and a picker sheet on mobile.
The tab picker only appears once "Active trip" is chosen, since there is
nothing for it to mean otherwise.

Labels come from the same keys the planner's tab bar uses, so the picker
can never name a tab differently from the tab itself.
2026-08-08 23:01:40 +02:00
Maurice 44eff7177e feat(client): open TREK on the active trip when asked to
Settings -> General -> Startup: start on the dashboard (unchanged default)
or straight in the active trip, on a tab of your choice. Three clicks and
three loads less for someone entering expenses while actually travelling,
which is where the request came from.

The redirect sits on '/' because that is what a shortcut, a bookmark and
the installed PWA all launch (manifest start_url). /dashboard keeps
working as itself - redirecting there too would leave no way back to it.

The preference is mirrored into localStorage, the way the language and
the theme snapshot already are, so the first paint can decide without
waiting on GET /api/settings. That keeps the default launch exactly as
fast as before: only someone who asked for the trip pays for the lookup,
and if it fails or they have no trip they get the dashboard. Right after
login the store already holds the real settings, so those win there.

Login stops hardcoding /dashboard as its fallback and hands the decision
to the same place, so there is one answer to "where does TREK open".
2026-08-08 23:01:40 +02:00
Maurice 688ce0fae2 feat(client): let a trip URL open on a given tab
/trips/:id?tab=finanzplan opens the planner straight on that tab. Read in
the state initializer, not an effect, so the planner never paints the plan
view and swaps a frame later. The param is consumed on arrival; the
session memory keeps the choice across a reload.

Two things the tab machinery got away with while nothing ever started on a
tab other than plan:

- the guard evicted anything not in TRIP_TABS, but enabledAddons is an
  optimistic guess until the addon feed answers, and collab is guessed off
  - so a requested collab tab was thrown away a render before we knew the
  addon was on. It now waits for the feed, like it already did for plugins.
- the lazy loads hang off handleTabChange, which the tab we *start* on
  never goes through. Opening on Files showed an empty list, and that was
  already true for a tab restored from a previous visit.

Tab ids move to constants/tripTabs so the settings picker can name them
the way the tab bar does instead of keeping a second list.
2026-08-08 23:01:40 +02:00
Maurice 99ee307034 feat(server): expose the active trip as its own endpoint
GET /api/trips/active answers with the trip a user means by "my trip"
right now: the one running today, else the next one starting, else the
most recently started. Archived trips never qualify.

That order is the dashboard hero's (sortTrips), on purpose - the startup
redirect this is for has to land on the same trip the dashboard features,
or the two would disagree about which trip is yours.

Kept apart from list() because it runs on a first paint: four columns of
one row instead of every trip with its per-trip day and place counts.
2026-08-08 23:01:40 +02:00
Maurice aca6c556ed test(server): cover the Synology provider
Completes the pass over the providers that moved into the gated tree. The
session handling is the part that most needed it: Synology invalidates a SID on
timeout (106), on a duplicate login (107) and when it does not recognise it
(119), and the service is supposed to clear it, re-login once and repeat the
call. That retry had no case at all — a regression there would have looked like
"Synology randomly stops working" and nothing would have failed.

Also pinned: a SID that no longer decrypts after a key rotation triggers a fresh
login rather than a dead session; a stored device id rides along so a trusted
device skips OTP; an app-level error that is not a session code is NOT retried;
the album listing merges personal, shared and shared-with-me, survives a partial
source failure, and collapses an id that appears in two sources; and an SSRF
block stays a 400 with the guard's own message while any other transport failure
is a 500.
2026-08-08 22:27:56 +02:00
Maurice c0d90d57b2 refactor(server): fold the memories domain into providers
The seven files in services/memories/ become injectable services. Every SQL
statement, error string, status code and header moves verbatim; what changes is
who owns what.

photoResolverService was two files in one, so it stayed two: the trek_photos
rows are a repository under photos/, the provider dispatch is
PhotoResolverService under memories/. That is the photos/ vs memories/ boundary —
storage there, dispatch here — and it is also why the module graph is a straight
line: TrekPhotosModule is a leaf both halves import, so neither needs a
forwardRef.

pipeAsset and the ServiceResult envelope stay free functions over the Express
response. pipeAsset is deliberately not a StreamableFile: it forwards the
upstream 206 and Content-Range so a <video> can seek (#823), swaps Cache-Control
to no-store on an error status, and answers differently once headers are sent.

The trek-photo cache keeps its in-flight map at module scope. That map is the
stampede guard, and the scheduler builds its own instance through a lazy require
outside the container — a per-instance map would give them separate guards and
ten tiles would fetch the same asset ten times, silently.

journeyService's dynamic import() of synologyService is gone: it existed only to
duck the import cycle, which no longer exists.

MemoriesModule imports RealtimeModule explicitly even though it is @Global, so a
single-domain e2e TestingModule can still resolve the broadcast.

The providers arrived at 60% branch coverage or less, because services/memories/
sits outside the gate and the integration suites drive the domain over HTTP
without reaching the failure paths. New suites cover what had no case at all:
the access checks that decide who may fetch someone else's photo, the stampede
guard, the thumbnail mtime check, the poster-less-video 404, and Immich end to
end with safeFetch stubbed — including the hidden-asset filter older servers
depend on (#1474) and the http -> https canonical detection that must NOT
rewrite a changed host.
2026-08-08 22:27:56 +02:00
Maurice 5b6c79299f docs(server): record what the coverage gate does and does not measure
PR #1844 went red with every suite green: the diff moved MCP tools from
src/mcp/, which the gate ignores, into src/nest/**, which it measures. That did
not change any behaviour - it revealed that airports.mcp.ts had never had a test
in either world, and dragged the aggregate to 79.86%.

Worth stating in the gotcha list, because journey.ts and _shared.ts still have
to make the same move.
2026-08-08 22:27:56 +02:00
Maurice 67c1633155 refactor(server): lift the memories vocabulary and the uploads anchor into nest
Two preparations the provider conversion needs, and neither changes behaviour.

The ServiceResult envelope, the provider-agnostic asset shapes and pipeAsset
move to nest/memories/memories.helpers.ts, which touches no database. The
controllers and the provider services can name them without importing each
other - the same reason notifications/notification-events.ts exists. What is
left in services/memories/helpersService.ts is the DB-backed half (the access
checks, the album-link lookups) and it folds with the providers.

pipeAsset stays a free function over the Express response on purpose. It is not
a StreamableFile candidate: it forwards the upstream's 206 and Content-Range so
a <video> can seek (#823), swaps Cache-Control to no-store on an error status,
and has to answer differently once headers are already sent. All three
controllers take @Res() anyway.

The three copies of `path.join(__dirname, '../../../uploads')` become one
UPLOADS_ROOT. __dirname moves with the file and none of these would have thrown
on a wrong depth - mkdirSync and multer create the wrong tree without a word,
and every stored photo lands somewhere nobody serves from. UPLOADS-ROOT-001
pins it. The depth happens to be identical for nest/memories, so nothing moves
today; the point is that the next move only has one place to get wrong.
2026-08-08 22:27:56 +02:00
Maurice aa4ed69677 refactor(server): give trek_photos its own repository
photoResolverService was two files in one. One half registers and drops rows in
trek_photos and touches no photo provider at all; the other dispatches a stream
or an info lookup to immich or synology. Keeping them together meant every
consumer that only wanted to store a row - the unified trip-photo service, the
journey service - dragged both providers in behind it.

The storage half becomes TrekPhotosRepository in nest/photos, over
DatabaseService. That is also where the photos/ vs memories/ line belongs:
storage here, provider dispatch with memories. photos.bridge.ts serves the two
modules still in services/ that register rows, and dies with them.

Every statement moved verbatim, including the two rules worth stating out loud:
getOrCreate re-encrypts a passphrase onto an existing row rather than inserting
a second one, and deleteIfOrphan never deletes a local photo, because its bytes
are ours and the file would outlive the row.
2026-08-08 22:27:56 +02:00
Maurice 7b666736df test(server): cover the MCP surfaces that moved into the gated tree
The coverage gate reads src/nest/** only, so the tools that lived in src/mcp/
were never measured. Moving them exposed what was never covered:

- airports.mcp.ts sat at 0% branches - search_airports and get_airport had no
  test at all, in either world
- the two weather catch blocks, which turn a provider failure into an isError
  result instead of a transport-level error
- the transport guards: trip access, day ids belonging to another trip, the
  not-a-transport-type check on update, and delete_transport in full
- the price path, where a transport with a price links a budget item and
  broadcasts budget:created

None of this is new behaviour; it is behaviour that shipped untested because it
sat outside the measured tree.

One case documents a subtlety worth keeping: the weather fallback message only
fires for a rejection with no .message at all. `new Error('')` still carries an
empty one and passes straight through, so the case rejects with a non-Error.
2026-08-08 21:20:36 +02:00
Maurice 3efedd5e48 refactor(server): decorate the last three MCP prompts
The prompts registrar is gone. Each prompt moves to the domain that owns its
data instead of a shared file that had to import three bridges to serve them.

packing-list goes to PackingMcp and budget-overview to BudgetMcp, both reusing
the `when` addon gate their tools already carry — so a disabled addon keeps the
prompt out of the listing, exactly as the `if (isAddonEnabled(...))` around the
registration call did. Both keep taking the trip title through trips.bridge:
TripsModule imports PackingModule and BudgetModule, so injecting TripsService
would close a cycle. Same documented in-container exception as atlas.mcp.ts, and
the registrar reached for it the same way.

token_auth_notice goes to a new AuthMcp — it is a statement about how the
session authenticated, not about trip data, and it injects nothing because the
message is static. Its `if (isStaticToken)` guard becomes `when: (ctx) =>
ctx.isStaticToken`; the registry hands `when` the session context, so an
OAuth 2.1 session never sees the prompt listed at all, which is what the
registrar achieved by skipping registration.

src/mcp/tools/ is down to _shared.ts and journey.ts. journey stays until
services/journeyService folds, which waits on memories.
2026-08-08 21:20:36 +02:00
Maurice cacab0795f refactor(server): decorate the weather, airport and transport MCP tools
Three of the four remaining hand-written registrars are gone. The tools keep
their names, descriptions, input schemas, annotations and result shapes; the
registration-time scope gates (`if (canWeather)`, `if (canGeo)`,
`if (!canWrite(scopes, 'reservations')) return`) become declarative access
markers, resolved by trekMcpAccessPolicy like every other decorated domain.

get_weather / get_detailed_weather move into a new WeatherMcp over the injected
WeatherService, including the catch that answers with the upstream message and
isError rather than throwing - a client sees "Weather service not available."
instead of a transport failure when the provider is down.

search_airports / get_airport move into a new AirportsMcp with no injected
service at all: the airport table is a static dataset. AirportsService owns the
flight-endpoint backfill against the database, which neither tool touches.

create/update/delete_transport move into ReservationsMcp rather than a domain of
their own, because a transport IS a reservation - same table, same service, same
reservation_edit permission - and that class already injects everything they
needed from the four bridges the registrar was importing.

That was budget.bridge's last outside-container consumer, so
linkBudgetItemToReservation is pruned with it; the file is down to one export and
dies as soon as BudgetMcp stops needing AuthService.

journey.ts stays: it still reaches into services/journeyService, which cannot
fold until memories does.
2026-08-08 21:20:36 +02:00
Maurice 790dd3b728 refactor(server): break the memories import cycle
immich -> unified -> photoResolver -> immich was a real cycle, every edge a
value import; journeyService only got past it with a dynamic import(). Nothing
in that cluster can become a provider while it stands, because Nest would need a
forwardRef through three modules.

It had exactly one cause: syncAlbumAssets and syncSynologyAlbumLink called
addTripPhotos, which lives in unifiedService. Both functions are two things
stapled together - fetch the album from the provider, then write it to the trip
- and only the first half is provider-specific.

So the providers keep the half they own (collectAlbumSelection,
collectSynologyAlbumSelection) and unifiedService keeps the half it owns
(syncImmichAlbum, syncSynologyAlbum). Every error string, status code and return
shape is the one the combined function produced, including the per-provider
wording the split had to preserve ('Could not reach Immich' stays in immich).

The plan called for pulling addTripPhotos and getOrCreateTrekPhoto into one
neutral module instead. Together they do not work as a pair: such a module still
needs immich and synology to fetch, so the cycle only moves.

There is a second cut that works - photoResolverService is really two files, and
its trek_photos CRUD half touches no provider, so splitting that out also opens
both loops with less churn. It is not the one taken here because it leaves
immich -> unified standing, i.e. a provider depending on the trip aggregate.
That direction is what tied the knot in the first place. The CRUD split is still
worth doing, but as the photos/ vs memories/ module boundary, not as the cycle
fix.

server/src/services/memories/ is a DAG now: helpers <- immich/synology <-
photoResolver <- unified.
2026-08-08 12:43:29 +02:00
Maurice 97f524cdfd feat(server): give the memories endpoints their Zod contracts
The last nine memories entries in the body-contract allow list are gone: three
each on the immich, synology and unified controllers. They read @Body() as
Record<string, unknown> and coerced by hand.

The schemas are deliberately permissive and loose, and that is the point rather
than a shortcut. Paging arrives as `2` or `'2'` because the controller does
`Number(page) || 1`; synology_skip_ssl arrives as a boolean or the string
'true'; auto_upload is accepted in any shape because only a real boolean is
acted on. A schema that described the ideal body instead of the real one would
turn working clients into 400s, and parity outranks tightening. Narrowing them
is a separate, client-visible change.

Loose objects for the same reason: a strict object strips unknown keys, and
`selections[]` goes straight to the services as `Selection[]` - stripping there
would be a silent data loss the day a provider adds a field.

This unblocks be-memories-fold, which could not move a controller whose body had
no contract to move with it.
2026-08-08 12:43:29 +02:00
Maurice ac96644508 refactor(server): fold the notifications domain out of services/
Four files taken apart rather than moved, because they only worked as a knot.

The mailer becomes its own leaf module. AuthService sends the password-reset
mail while NotificationsModule imports AuthModule for the demo gate in
NotificationsMcp - a mailer inside the notifications domain would have closed a
hard cycle that only a forwardRef could open again. Webhook and ntfy stay with
notifications; auth has no business importing them.

The channel registry stays a module singleton and does NOT become a provider.
PluginRuntimeService pushes its channel getter in at onModuleInit, and
notifications.bridge.ts builds its own NotificationsService for the scheduler
crons and the memories senders. A provider would hand the bridge a second, empty
registry - every plugin channel would then go quiet with no error anywhere.
CHOVR-015 drives that seam end to end: a channel published through the runtime,
delivered through the bridge. Nothing pinned it before; the existing cases all
set the source themselves.

The built-ins no longer self-register by side-effect import. They used to exist
only because notificationPreferencesService happened to `import
'./notifications/builtins'`; removing that line would have silenced email,
webhook and ntfy without a failure. They are built from the injected transports
and registered from NotificationsService's constructor, so both instances have
them.

No cached nodemailer transport: MailerService.createTransport re-reads the SMTP
config and skipTlsVerify per send, exactly as the three inline call sites did,
so an admin changing them does not have to restart.

Preferences become a provider over DatabaseService. Eight places built
NotificationsService by hand, so a tests/helpers/notifications.ts factory keeps
the next constructor parameter a one-file diff instead of an eight-file one.
2026-08-08 12:09:56 +02:00
Maurice 712a33626a refactor(server): give the notification vocabulary its own module
The event names, the channel ids and the ExternalChannel contract move to
nest/notifications/notification-events.ts, which imports nothing.

The cluster only looked acyclic: notifications.ts and channelRegistry.ts both
reached back into notificationPreferencesService for NotifEventType, and both
edges were `import type`, so they compiled away. Putting any of those names in a
value position - an enum, a const array, an injected class - would have made the
cycle real, with builtins.ts as a third ring. Nothing can fold while that is
true.

The drift guard against the shared NotificationEventKey moves with the union it
guards; it sat three files away from it.
2026-08-08 12:09:56 +02:00
Maurice 03493a2edd refactor(server): fold webauthn, account erasure, oauth and the wiki out of services/ (#1840)
* refactor(server): move the webauthn config and the account erasure into nest/auth

webauthnConfig resolved the RP ID through its own getSetting() on the db
singleton; it is a WebauthnConfigService over DatabaseService now, injected into
AuthService (passkey_configured) and PasskeyService (four ceremonies). readEnv()
is still read per call, so an operator flipping the RP ID at runtime takes effect
on the next ceremony instead of after a restart.

userCleanupService follows as UserCleanupService. It keeps taking
removeUserFromBudgetItems from budget.bridge rather than injecting BudgetService:
BudgetModule imports AuthModule for the demo guard in BudgetMcp, so AuthModule
cannot import BudgetModule back, and breaking that edge the other way drags
BudgetController, BudgetMcp and its trips.bridge chain into every graph that
imports AuthModule.

The legacy module had no tests of its own - the erasure only ever ran through
AdminService.deleteUser and TripsService.deleteGuest - so USER-CLEANUP-001..009
pin both halves, including the orphan data dir that no permissions row can
describe and the all-or-nothing deletion transaction.

* refactor(server): fold the oauth 2.1 server out of services/

694 lines move into OauthService over DatabaseService, AddonsService and
AuditService. The thin wrapper that only forwarded is gone, and so is the second
path to the MCP addon flag - validateAuthorizeRequest read it through
addons.bridge while the wrapper read it through the injected service, which would
have let the two drift.

The pending authorization codes stay a module singleton in oauth.pending-codes.ts
instead of becoming instance state. The SDK still serves /oauth/authorize,
/oauth/register and /oauth/consent from its own router, mounted on Express before
app.init(), so it reaches the domain through the new oauth.bridge.ts - two maps
would break the authorization-code flow silently, since the SDK only ever answers
"Authorization grant is invalid.". The import-time sweep keeps its .unref() and is
a named function now so a test can drive it.

scopes and sessionManager are imported deep, never through the src/mcp barrel:
the barrel re-exports revokeUserSessionsForClient and pulls the domain bridges
back in, which is the nest -> mcp -> nest cycle. Same comment as admin.service and
auth.service carry.

The 70 cases of the legacy suite moved over unchanged, bound to a hand-built
service. Fifteen more cover what free functions never let anyone reach: the code
store at capacity, an expired code, an expired access token, refresh against a
public and an unknown client, the RFC 8707 resource checks, and that a code
written through the container is read back through the bridge.

* refactor(server): move the wiki reader into the help module

wikiService had exactly one consumer, HelpController, and touches no database -
it stays a plain module rather than becoming a provider, the way
auth/ephemeral-tokens.ts does, so the text and asset caches keep being one per
process without any ceremony around it.

The WIKI_DIR anchor needed one more level: src/services sits three below the repo
root, src/nest/help sits four. The existing path-resolution case caught it, which
is what it is there for - a wrong anchor makes help degrade to the GitHub fallback
without anybody noticing.

Also brings the nest README back in line: services/ is down to five top-level
files, and the passkey and budget entries no longer point at paths that were
deleted since they were written.
2026-08-08 11:14:24 +02:00
Maurice 5eab8128bd refactor(server): fold the ephemeral-token store and the in-app action registry (#1836)
Two small leftovers, moved to the domains that own them.

ephemeralTokens holds the short-lived tokens the WebSocket handshake and the
file-download links use. It already had startTokenCleanup/stopTokenCleanup with
an unref-ed handle — the right shape. What was missing is anyone calling stop:
index.ts kicked the sweep off with a bare require() after listen() and nothing
ever ended it. AuthService owns that lifecycle now, through
onModuleInit/onModuleDestroy, which is where it belongs — it is the service that
issues the tokens.

The store itself stays module state. websocket.ts consumes tokens from outside
the container, and a token store is only useful if the issuer and the consumer
share one. Same reasoning as the weather cache in #1835.

inAppNotificationActions is a 22-line handler registry with exactly one
consumer; it moves next to it as notifications/in-app-actions.ts.

services/ is down to 9 files, from 44.
2026-08-07 22:18:14 +02:00
Maurice a7d789fdd5 refactor(server): move weather out of services/, give its cache sweep a lifecycle (#1835)
Finishes be-weather-airports-fold. The README has listed weather as the
reference implementation since the migration started, while 513 lines of it sat
in the legacy layer.

The file is nest/weather/weather.impl.ts now, and WeatherService owns what it
could not own before: the cache sweep. That sweep was a bare setInterval at
module scope, so every process that so much as imported the file started a
five-minute timer and nothing ever stopped it — there was nothing to call. It is
startCacheCleanup/stopCacheCleanup now, driven by onModuleInit/onModuleDestroy,
and the handle is unref-ed so a cache sweep can never hold the process open.

The old test file says as much: its second line is "Prevent the module-level
setInterval from running during tests", with a vi.useFakeTimers() to do it. That
workaround is no longer what keeps the timer quiet.

The cache itself stays module state, deliberately. The MCP weather tools call
getWeather directly from outside the container, and a cache is only worth having
if every caller sees the same one. Hoisting it into the class would mean handing
the registrar the container instance to get exactly what a module singleton
already provides — the same trade the place-photo cache resolved the other way,
because there the scheduler needed the instance anyway.

Coverage is the reason this PR carries eleven new tests rather than none. Code
in services/ is ungated; the moment it lands in src/nest it counts against the
80% branch threshold, and weather.impl arrived at 75%. The gap was in paths
worth having: a failing archive lookup with and without an upstream reason, a
far-future date whose climate rows are all null, an expired entry being re-
fetched, and the sweep evicting one on its own. Plus the lifecycle itself —
start is idempotent, stop is idempotent, the handle is unref-ed. 79% now.

services/ is down to 11 files, from 44.
2026-08-07 21:47:21 +02:00
Maurice 9c46f31c57 refactor(server): fold airports and distance, drop the boot-time backfill cycle (#1834)
Two thirds of be-weather-airports-fold. weatherService (513 LOC, a process
cache and a setInterval at import) is left for its own PR.

airportService split along the line that was already there. The dataset lookup
touches no database — it reads one JSON file and keeps it in memory — and both
MCP registrars call it from outside the container, so findByIata/searchAirports
are free functions in nest/airports/airports.data.ts. backfillFlightEndpoints
needs the connection and became a method on AirportsService.

That backfill was the interesting part. db/database.ts ran it at module eval
through a bare require() back into services/, so the module graph closed a cycle
that only held because `db` happened to be initialised by the time the require
resolved. It runs in onApplicationBootstrap now: it repairs rows, nothing boots
on it, and the container is fully built by then.

The cycle had a visible cost. tests/setup.console-noise.ts carried a suppression
for `Cannot find module '../services/airportService'`, because vitest's
transform pipeline could not resolve that require at all — it fired in roughly
sixty test files. That suppression is now narrowed to the only case left: an e2e
TestingModule whose fixture DB has no reservations table.

distanceService split the same way: haversineKm is arithmetic and moved to
common/geo.ts; getFlightDistanceKm reads the database and became a private
method on AuthService, its only caller.

AirportsModule imports DatabaseModule explicitly. @Global only reaches modules
that are in the graph, and the airports e2e builds its container from
AirportsModule alone — exactly the trap the nest README now documents.

The search ranking gets its first direct tests. It sat in services/, outside the
coverage gate, and only ever ran incidentally through the route; moving it into
src/nest made the gap measurable (16% branches). The ten cases pin the scoring
ladder — exact IATA short-circuit, ICAO over prefix, IATA prefix over city
prefix, city prefix over substring — and the missing-dataset fallback, which no
test had reached. 90% branches now.
2026-08-07 21:07:14 +02:00
Maurice c7fed4b1f8 refactor(server): make the place-photo cache a provider (#1833)
The last file in be-places-helpers-fold, and the one that had to be a provider
rather than a move: it carries three in-memory structures that only do their job
if the process has exactly one of them.

  recentFailures  stops a screenful of markers hammering an API that is down
  inFlight        stops a stampede when several requests miss the same placeId
  knownOnDisk     saves an existsSync per cache hit

A second instance gives the stampede guard its own inFlight map, so two
simultaneous requests for the same uncached place both call the provider. That
is why the scheduler takes this one from the container instead of constructing
it: SchedulerDeps gains a placePhotos entry alongside backups, filled from
app.get() in index.ts — the shape PR #1827 was built for.

The module-scope mkdirSync becomes onModuleInit, and GOOGLE_PHOTO_DIR is read
per call through RuntimeEnvService rather than frozen at import. That is not
cosmetic: TREK_PLACE_PHOTO_DIR is the seam the tests use to keep off the real
uploads tree, and its own test used to have to import the module dynamically to
set the variable in time. The __dirname fallback moved a level with the file and
is corrected accordingly.

Consumers: maps, places and share inject it; places.helpers.reclaimPhotoCache
takes it as a parameter, since it is a free function.

Six test seams follow. Four path mocks become injected stubs with the same mock
functions behind them; the collections case keeps the REAL implementation,
because what it asserts is that removeIfUnreferenced counts collection_places as
a reference (#1081) — a stub there would assert nothing. The share e2e overrides
the provider in its container.

services/ is down to 14 top-level files, from 44 when this started.
2026-08-07 20:27:19 +02:00
Maurice 4e6b45016a refactor(server): move the KML importer and place-image helpers into places/ (#1832)
Two of the three files in be-places-helpers-fold. placePhotoCache is left for
its own PR — it holds three module-level caches and its sweep hangs off the
cron, so it needs both a provider with real state and a second entry in the
scheduler contract.

kmlImport.ts is 212 lines with no state, no database and no I/O — parsing,
sanitising and coordinate arithmetic. It is nest/places/kml-import.helpers.ts
now, unchanged, next to the places.helpers.ts that already consumes it.

placeImage.ts moves to nest/places/place-image.ts as free functions. Making
reclaimPlaceImage a provider would have split it from PLACE_IMAGES_DIR, which
cannot be injected: nest/common/place-image-upload.ts reads it at module scope
to configure multer's storage, before any container exists.

That constant is the one thing here that could have broken quietly:

  path.resolve(__dirname, '../../uploads/places')

__dirname moves with the file. From src/services that resolved to
<server>/uploads/places; from src/nest/places the same string would have
resolved to <server>/src/uploads/places — a directory multer would have happily
created, leaving every uploaded place image in the wrong place and every
existing one unreachable. The path is now '../../../uploads/places', verified to
resolve to the identical absolute directory, and uploads-dirs.test.ts covers it.
2026-08-07 19:58:12 +02:00
Maurice 90e35e6970 refactor(server): fold llmConfig and unsplash out of services/ (#1831)
Two of the three leftovers in be-misc-fold. The third (wikiService) needs a Zod
contract in @trek/shared first and is left for its own PR.

llmConfig.ts was pure movement: 70 lines of encryption-aware config shaping with
no state and no database, sitting in services/ while all four of its callers are
in src/nest. It is nest/llm-parse/llm-config.ts now, same functions, same file.

unsplashService.ts needed an actual provider. Its key resolution reads both the
env and the users table (env var, then the caller's stored key, then any
admin's), so the injected connection was unavoidable. Its own module because
trips and places both reach it, and because folding it into either would have
made the other import a trip/place service for an image search.

The four exports became methods on UnsplashService. The SSRF guard in
saveUnsplashCover is untouched — it still refuses any host but the Unsplash
image CDN and still goes through safeFetch — as is the unauthenticated-endpoint
fallback when no key resolves (#1449).

The env read stays live through RuntimeEnvService rather than a registerAs
token. readEnv() is what the function did before, and a boot-frozen token would
change when an operator's UNSPLASH_ACCESS_KEY takes effect.

Two test seams follow the code: the unsplash unit tests bind the four methods so
every existing assertion reads unchanged, and the trips controller's path mock of
the module becomes an injected stub.

services/ is down to 17 top-level files.
2026-08-07 19:34:31 +02:00
Maurice 7dea88120b docs(server): bring the nest README up to the foundation work (#1830)
The README is the binding spec, and seven PRs have landed patterns it does not
mention. Someone reading it today would rebuild things that now exist, and would
walk into two traps that cost real time this week.

Added to the foundation section: common/ and why those helpers are free
functions, auth/jwt-verify.ts and why JWT_SECRET must not move to app-config,
query-helpers/, trip-membership/ and the cycle that put it in its own module,
AddonGuard + @RequireAddon with its ordering requirement, and demo-write.

New subsection on reaching the container from outside it. The choice is settled
now: hand the dependency in, declared structurally, the way index.ts fills the
scheduler's SchedulerDeps. Bridges stay correct for the MCP registrars, but the
README should say what they cost — a bridge rebuilds its services with `new`, so
one added constructor parameter meant editing five bridge files in a single PR.

Two gotchas that are cheap to read and expensive to rediscover:

  Never gate a multipart upload with a guard. Guards run before the parser, so
  throwing leaves the body unread and Node resets the connection — the client
  gets ECONNRESET rather than the 403.

  @Global only applies to modules that are in the graph. An e2e TestingModule
  built around one domain module does not get AppConfigModule for free, and a
  provider injecting RuntimeEnvService takes every suite importing that module
  down with it.

Also corrects an assumption the migration plan makes and this README implied:
the trip-access and canEdit methods on the domain services are not dead weight
waiting for TripAccessGuard. Across the five domains piloted for it, 40 of the
46 callers are *.mcp.ts tools, which never pass through an HTTP guard. Only 6
sit in controllers. Counted, not estimated.
2026-08-07 19:04:20 +02:00
Maurice 22d178d2f9 refactor(server): one addon guard, one demo-mode condition (#1829)
Two pieces of copy-paste in the guard layer.

Three addon guards — collections, airtrail, journey — did the same thing and
differed only in the addon id they read and the word leading the 404. They are
one AddonGuard plus a @RequireAddon(addonId, label) decorator now, read through
the Reflector. A handler-level decorator overrides the controller's, and a route
without the decorator is not gated at all.

The demo-mode upload block was copy-pasted into six handlers, each carrying its
own copy of the message. All six call isDemoWriteBlocked now, against the same
DEMO_WRITE_ERROR body.

That one is deliberately NOT a guard, though all six sites are handlers and a
guard is the obvious shape — and this PR did build one first. Guards run before
the multipart parser, so throwing from one leaves the request body unread and
Node resets the connection: the client gets ECONNRESET instead of the 403.
PROFILE-015 caught it. The check has to sit inside the handler, after the
interceptor has drained the upload, for the response to arrive at all. The
function carries that reasoning so the next person does not rediscover it.

Two smaller findings on the way there.

The DemoWriteGuard attempt broke eight e2e suites that never touch demo mode:
AuthModule listed it as a provider, and RuntimeEnvService was not resolvable
inside a TestingModule built around one domain module. AppConfigModule is
@Global, but @Global only applies to modules that are in the graph. The five
modules whose controllers now inject RuntimeEnvService import it explicitly, the
same way PermissionsModule is handled.

And both new guards get a test asserting the decorators are actually applied.
Once a check moves out of a handler, that handler's own tests stop noticing if
the decorator is dropped — the assertions on __guards__ and the @RequireAddon
metadata are what keeps the four upload endpoints and the four addon route
groups gated, and gated on the right addon.
2026-08-07 18:42:53 +02:00
Maurice 9814480a96 refactor(server): move the JWT verify into nest/auth, delete four dead middlewares (#1828)
middleware/auth.ts exported seven things. Two of them — extractToken and
verifyJwtAndLoadUser — are the canonical session check every auth surface goes
through. The other five (authenticate, requireCookieAuth, optionalAuth,
adminOnly, demoUploadBlock) are Express middlewares with no production caller
left; the guards in nest/auth replaced them and only comments still mention
them.

The two live functions move to nest/auth/jwt-verify.ts and the file goes, along
with middleware/validate.ts (nothing but its own test imported it) and
middleware/idempotency.ts (its only caller was `authenticate`, and
IdempotencyInterceptor has replaced it). AuthRequest and OptionalAuthRequest go
with them — src/typings/express.d.ts is what types req.user now.

Eight importers repoint: the three guards, auth/files/plugins-proxy services,
platform.routes and the global MFA policy.

Two deliberate departures from the plan for this package.

JWT_SECRET does not move to app-config. It is not an env value: it is generated
into data/.jwt_secret, and the admin panel rotates it at runtime through
updateJwtSecret. `export let` is precisely what makes a rotation take effect
in-process — a registerAs token would freeze the boot value and silently break
rotation.

The verify also keeps reading the db singleton rather than DatabaseService. That
change is not a one-line swap: it gives the three guards a constructor
dependency, which means registering them as providers everywhere they are used.
Measured: 38 directories use those guards and 21 of them do not import
AuthModule, on top of 34 e2e tests that each build a container around a single
domain module. That is its own PR, not a rider on one that deletes dead files.

tests/unit/middleware/auth.test.ts covered real behaviour, so it is not simply
deleted. Its extractToken cases and the five verify cases it drove through
`authenticate` come back as tests/unit/nest/auth/jwt-verify.test.ts, now hitting
verifyJwtAndLoadUser directly. Four cases are new: the password_version gate —
the whole reason this function exists — had no direct coverage at all. Its three
adminOnly cases are dropped because AdminGuard, their successor, is covered by
three e2e suites.
2026-08-07 18:01:24 +02:00
Maurice a634b2a26b feat(server): give the scheduler a contract with the container (#1827)
The scheduler runs outside the Nest container but needs four things inside it:
backupService, trekPhotoCache, placePhotoCache and airtrailSync. It reaches all
four with a require() or await import() at call time. This settles how that
should work before four separate folds each invent their own answer, and wires
one consumer as proof.

It takes the dependencies from the container rather than growing four more
bridges, for a reason that showed up while writing the previous PRs: adding one
constructor parameter to a service meant hand-editing five bridge files that
rebuild their services with `new`. app.get() has no such cost, and hands back
the container singleton instead of a second instance.

The scheduler declares what it needs structurally — a `backups` with a
createBackup — and never names BackupService. That is not fastidiousness: an
import of the class would pull in src/nest/backup → src/services/backupService →
src/scheduler, which is the exact cycle the lazy `await import()` in runBackup
existed to dodge. With the dependency handed in, the scheduler stops importing
backupService entirely and the cycle is gone; backupService still imports the
scheduler for VALID_INTERVALS, but now only in one direction.

index.ts fills the contract from the app buildApp() already initialised, right
before the first cron starts, the way bootstrap.ts hands the /mcp handler its
registry.

Two smaller things fall out. The Nest BackupService was swallowing
createBackup's prefix parameter, so it could only ever produce 'backup' names;
it forwards it now. And the scheduler can be started without a container at all
— its unit tests do exactly that — so runBackup says so in the log instead of
throwing, with a case covering it.

The other three consumers stay on their require() calls; they move with their
own folds.
2026-08-07 17:29:53 +02:00
Maurice 1e05501b9d refactor(client): finish the React 19 migration (#1826)
Mechanical, and it ends the migration visibly.

45 files imported the whole react-dom default export to reach one function.
They now import createPortal by name, across 54 call sites; six files already
did it this way. main.tsx keeps its react-dom/client import, which is a
different module and already correct.

forwardRef is gone from every component. In React 19 ref is an ordinary prop,
so the wrapper only obscured the signature:

  NumericInput   — a plain DOM ref, no imperative handle involved
  JourneyMap     — useImperativeHandle stays, only the wrapping goes
  JourneyMapGL   — same
  JourneyMapAuto — same

Each takes `ref?: Ref<Handle>` in its own Props interface now, so the ref is
part of the declared surface instead of arriving through a generic parameter.

The two journey bindings in glLazy shrink to one line each. They existed as
forwardRef wrappers purely to carry the ref past the lazy boundary; with ref an
ordinary prop it rides through the spread like everything else, which makes all
six bindings in that file identical again.

The five React.FC annotations go. Three were `Record<string, React.FC<{size?:
number}>>` for provider icons in files that never import React at all — those
only typechecked through the UMD global — and are now ComponentType. The two
pages become function declarations.

TranslationContext uses the 19 context shorthand, which leaves its React default
import unused, so that goes too.

Also drops two dead vi.mocks of react-dom/server. Their comment still said "used
in createMarkerIcon", which stopped being true when marker icons moved to the
in-house serializer — nothing under src/ imports react-dom/server any more
except the parity test that exists to compare against it.
2026-08-07 17:03:47 +02:00
Maurice eb8e777741 chore(client): move zustand to 5.0.14 (#1825)
The last React-18 leftover in the dependency tree. v4 declared
use-sync-external-store as a hard dependency and its main entry imported the
shim; v5 declares no dependencies at all and calls React.useSyncExternalStore
directly. Only zustand/traditional still reaches for the shim, and nothing here
imports it, so it no longer reaches the bundle.

The upgrade itself needed one line. What it needed more of was checking, because
the one thing v5 changes about behaviour does not show up as a test failure.

v4 subscribed through useSyncExternalStoreWithSelector, which memoised the
selector's result. v5 hands React a plain getSnapshot of
`() => selector(api.getState())` and compares with Object.is. A selector that
builds a fresh object or array therefore returns a new reference on every call,
and React reads that as "the store changed" — an infinite render loop, or at
best a re-render on every parent render.

All 294 store selectors return a property or a primitive. None builds an object
literal, array literal, filter/map/slice result, `?? []` default, Object.keys or
a new Set/Map, so none of them needed useShallow. The suite confirms it: 533
files, 11,165 tests, and zero "getSnapshot should be cached" or "Maximum update
depth" warnings anywhere in the output.

The one source change is the seedStore test helper, which was typed against a
hand-written `{ setState: (partial, replace?: boolean) => void }` shape. As of
v5 setState is an overload pair — partial with replace?: false, full state with
replace: true — and no single signature is assignable to that. Typing it against
zustand's own StoreApi fixes all 129 resulting errors and is what the helper
meant in the first place.
2026-08-07 16:34:18 +02:00
Maurice 939452f9d8 refactor(server): consolidate trip access and fold the query helpers into the container (#1824)
* refactor(server): route every trip-access check through DatabaseService

Twelve Nest services carry the same verifyTripAccess/canEdit pair. Six already
asked DatabaseService.canAccessTrip; the other six went through
services/tripAccess, a nine-line re-export of the identical function. Same
query, same result, two ways to reach it.

All twelve now take the injected route, so services/tripAccess is gone.

The bigger duplication was in the types. Thirteen files declared their own
`Trip` for the same return value, in three different shapes:

  NonNullable<ReturnType<typeof verifyTripAccess>>   6x
  { user_id: number }                                4x
  TripAccess                                         3x

The narrow one is why assignments, days, places and accommodations all wrote
`as Trip | null | undefined` on the call — the cast existed to shrink
TripAccess down to a local type that was never meant to differ from it. All
thirteen now say TripAccess, and the four casts are gone with no other change.

middleware/tripAccess.ts goes too: it was already dead in production — nothing
under src/ imported requireTripAccess or requireTripOwner, only comments in the
services that had absorbed them. Its test went with it.

* refactor(server): fold the query helpers and trip joins into the container

Two more legacy files whose only reason for staying outside the container was
that they predated it.

services/queryHelpers.ts held five exports doing two different jobs. Three are
the batch loaders that keep the list endpoints off N+1 queries — they hit the
database and are now QueryHelpersService over the injected connection, imported
by assignments, days, places and share. The other two, formatAssignmentWithPlace
and ratingAggregate, touch no database at all; they sat next to the loaders only
because the loaders were free functions too. Those stay free functions, in
common/rowShape.ts.

The README note on the assignments service claimed the loaders had to stay in
services/ because the day, share and place services were unmigrated. All three
have since moved, so the reason is gone.

services/tripMembership.ts becomes TripMembershipService in its own module. It
could have folded into auth or trip-invite, but both call it — as do oidc and
the plugin RPC host — and putting it in either would have made AuthModule and
TripInviteModule import each other.

Neither module is @Global, matching PermissionsModule: e2e TestingModules
resolve them transitively through each consumer's explicit import.

Test seams changed shape accordingly. Two path mocks of services/tripMembership
became injected stubs; the trip-invite e2e overrides the provider in the
container instead. The oidc test keeps the real implementation, which is what
OIDC-SVC-045 was always asserting.

ratingAggregate moved out of the ungated services/ tree into the coverage-gated
one, so it gets the direct tests it never had — as does the tags/participants
pass-through, which two long-dead fixture variables in the moved test file had
been waiting for.

Also drops four stale comments that outlived their subject: the requireTripAccess
middleware they pointed at was deleted in the previous commit.
2026-08-07 16:00:12 +02:00
Maurice 6dda05f672 refactor(server): move the stateless helpers into nest/common (#1823)
Eight files in services/ hold no state and touch no database: avatarUrl,
conflictResult, demo, passwordPolicy, timezoneService, cookie, and the two
crypto helpers. They are imported from 65 places, 62 of them already inside
src/nest, so having them live in the legacy layer was pure inertia.

They stay plain functions rather than becoming providers. Three importers sit
outside the container and would have no way to reach an injectable one:
db/migrations.ts runs before the app is built, and middleware/auth.ts and
middleware/mfaPolicy.ts run beside it. Wrapping these in DI would buy ceremony
and nothing else.

mfaCrypto and apiKeyCrypto move together into common/crypto — they read the
same ENCRYPTION_KEY, and the directory says so.

services/ drops from 44 files to 36, 9,870 lines to 8,493.

One trap worth recording: src/app-config and src/nest/app-config both exist.
cookie.ts imported '../app-config' from services/, which resolved to the former;
after the move the same string silently pointed at the latter, which has no
readEnv. It is now explicit about which one it wants.

Nothing else changes — no signatures, no behaviour, no tests rewritten beyond
their import paths.
2026-08-07 15:14:14 +02:00
Maurice 3dfbb3fd02 perf(planner): load the PDF export and the transport modal on demand (#1821)
Two more things every trip paid for whether or not anyone used them.

downloadTripPDF hung statically off the days sidebar, so opening any trip
pulled in the whole PDF builder. Both call sites are already async and already
wrap the call in try/catch, so a failed chunk surfaces as the export error the
handler had anyway.

TransportModal was already rendered conditionally, which means lazy bites
immediately. It is worth more than its own 32 kB: it is the only path to
TransitSearchPanel, and that chunk carried tz-lookup — about 200 kB of packed
zone geometry.

  TripPlannerPage      339.78 -> 305.70 kB
  entry chunk          258.24 -> 218.70 kB
  TransitSearchPanel   198.67 ->  26.51 kB   (and now on demand)
  TripPDF              226.02 ->  27.78 kB   (and now on demand)
  eager path           unchanged at 1,483 kB

Worth stating plainly: tz-lookup itself did not go away. It sits in
PlaceFormModal.helpers now, and PlaceFormModal is still a static import of the
planner, so a trip still loads it. Prying that loose means touching
placeOpenState, whose consumers assume a synchronous lookup — a separate change
with its own care.
2026-08-07 14:38:25 +02:00
Maurice cf62567c48 fix(map): stop untrusted values reaching the marker HTML (#1820)
* fix(map): drop the casing layer before its source on teardown

Tearing down the reservation overlay logged an error on every map unmount:

  Source "trek-reservations" cannot be removed while layer
  "trek-reservations-lines-transit-casing" is using it.

The casing layer arrived with the transit paths in 3.2.0 but was never added to
destroy(), which only removed the main line layer. The try/catch around it gave
no protection either — the engine reports this by firing an error event rather
than throwing — so the source was silently left behind on the style.

The layer id is a named constant now, so the place that creates it and the place
that removes it can no longer drift apart.

The lifecycle test grew the assertion that would have caught it: the fake map
now refuses to remove a source that a layer still references, the same way the
real one does. Verified against the unfixed code — two tests fail without the
change.

* fix(map): stop untrusted values reaching the marker HTML

Both map renderers build their marker HTML as a string and hand it to
L.divIcon / innerHTML. Two of the interpolations carry values a user controls,
and neither was escaped.

place.image_url is the worse of the two, because it is not admin-only: any trip
member with edit rights can set it. placeUpdateRequestSchema is a plain record
of unknown values, and the length guard in places.controller covers name,
description, address and notes but not image_url. The renderers do check the
value, but only with startsWith('/uploads/') — a payload like

  /uploads/x" onerror="…" y="

satisfies that and then breaks out of the src="…" attribute.

category.color is the same shape one line up, reachable by an admin. It also
reaches SharedTripPage, which builds its own divIcon and answers without a
guard, so that payload lands in front of people who have no account here.

Colours are allow-listed rather than escaped. Escaping stops the attribute
breakout but leaves a working CSS value behind, and `url(https://…)` in a
background is a tracking pixel. Anything that is not #rgb/#rrggbb falls back.
image_url is a URL, not an enum, so that one is escaped; the prefix check stays.

Server side, the category contract now demands a hex colour, and the endpoints
validate through DTO classes instead of @Body('color') — a property read carries
no metatype, so the global pipe never saw it. That retires two entries from the
body-contract allow list. It replaces the hand-rolled 400 for a missing name
with the pipe's envelope, the same trade the places and trips migrations made.

The response schema deliberately still accepts any string: the write path was
open for a long time, so tightening it would make an instance fail on its own
stored rows. The client allow-list is what covers those.

Verified against the unpatched renderer — the three new cases fail without it.

* perf(client): serialize the map icons without react-dom/server

Six modules imported react-dom/server statically to turn a lucide icon into an
SVG string. That put Fizz — roughly 190 kB raw, 57 kB gzip — into a chunk three
lazy routes share, the Leaflet renderer among them, which is the default. For
output that is always a single <svg> with a flat list of shape children.

  mapViewport chunk   189,532 -> 3,710 B
  Tooltip chunk       194,333 -> 1,970 + 4,860 B
  precache total      17,856 -> 17,675 KiB

utils/iconMarkup.ts serializes React elements directly. It is generic over
elements rather than over lucide's icon tables on purpose: iconNode is closed
over inside createLucideIcon and never exported, so calling the forwardRef
render function is what survives a lucide upgrade.

Byte-compatibility is checked, not asserted. iconMarkup.parity.test.ts diffs the
output against real Fizz for every exported icon in three prop shapes — the test
file is the one place still allowed to import react-dom/server, and it never
reaches a bundle. Two traps it covers: viewBox must not be kebab-cased into
view-box, and Fizz emits <path …></path> rather than a self-closing tag.

TripPDF loses its lazy renderer entirely — ensureRenderer, the module-level
handle and the two "not ready yet" guards that returned an empty string. There
is nothing left to load.

An eslint rule keeps the import from creeping back. Without it the regression is
invisible: the build stays green, the app keeps working, and the chunk quietly
grows by 190 kB again.
2026-08-07 13:43:38 +02:00
Maurice c089e75f08 perf(map): load one GL engine instead of both (#1819)
* perf(map): load one GL engine instead of both

MapViewGL, JourneyMapGL and the settings preview each imported mapbox-gl and
maplibre-gl statically, so rollup emitted one 2.83 MB chunk carrying both.
Every map user downloaded 1.8 MB of mapbox plus 1.03 MB of maplibre and then
ran exactly one of them. The 110 kB stylesheet was merged the same way.

  mapbox users     2,827,647 -> 1,797,952 B   (-1.03 MB, CSS 110.6 -> 40.8 kB)
  maplibre users   2,827,647 -> 1,027,635 B   (-1.80 MB, CSS 110.6 -> 69.8 kB)

The engine is a prop now rather than a module import, and the pairing happens
behind the lazy boundary in glLazy.tsx. No module reaches both SDKs statically
any more, which is the whole mechanism — the components themselves stayed
engine-agnostic, they already guarded on the provider everywhere the two
genuinely differ.

Both imports of a pair start together instead of in sequence: the engine is by
far the larger half, and fetching the component first would put its round trip
in front of the download that actually costs time.

check:gl-split guards it. A stray static import anywhere in the map tree brings
the merged chunk straight back with a green build and a working app, so the
script greps the built chunks for a marker of each SDK and fails when one file
holds both. Not on the bare string "maplibre" — the provider value and the
maplibre_style setting key appear in app code too, so that matches the shared
core chunk as well.

Two boundaries were missing and are added here, because the split makes them
reachable: the GL boundaries had no resetKeys, so a chunk failure under one
provider kept showing Leaflet after switching to the other, and the settings
preview had a Suspense but no boundary at all — that is the one place a user
flips providers live, so it is the likeliest chunk to fail.

Worth being precise about the gain: the service worker precaches every chunk,
so an installed PWA still downloads both engines. What this buys is the
critical path on a cold cache, and parse/compile/execute always — a megabyte
of unused engine ran on every map open.

* perf(client): split out the video player and the ISO subdivision data

Two heavyweights that shipped with routes that rarely use them.

plyr (112 kB raw / 33 kB gzip, plus 32 kB of CSS) hung off three lightboxes and
through them off the FileManager, JournalBody and MTripShell chunks. The
lightboxes only render the player on their video branch anyway, so the split
costs one frame of placeholder — a black rectangle in the target geometry, so
the lightbox does not jump when the chunk lands.

iso-3166-2 is a single 238 kB data blob with no tree-shaking to be had, and
only fetchRegionOptions touches it. It now loads after the hasRegions check,
so it is fetched only for countries that get a region picker at all:
holidayRegions drops from 257 kB to 14 kB. The import sits inside the existing
try — a failed chunk yields the same empty array as a failed request, rather
than an unhandled rejection in the two callers that only do .then(setRegions).

tz-lookup is deliberately left alone. TransitSearchPanel builds the departure
time it sends to MOTIS out of tzAt(), so a not-yet-loaded window there is a
wrong query rather than a late pixel — that one needs its own change with its
own care.
2026-08-07 12:46:03 +02:00
Maurice 49e68fb07c build(client): give react and the core libs their own chunks (#1818)
Everything that changes with a release currently drags the entry chunk with
it, and 63 of 141 chunks import the entry statically. Measured with a
one-token edit to a leaf route module and a rebuild, 70 of 141 chunks rehash —
just over 1 MB gzip that every installed PWA re-downloads per deploy, since
the service worker registers with autoUpdate.

Pulling react and the core libraries out cuts that to 847 kB:

  rehashed   70/141 -> 60/140
  gzip       1,038,497 B -> 847,688 B   (-18.4%)
  entry      580,672 B -> 258,105 B
  eager JS   1,482,722 B -> 1,482,523 B (unchanged)
  eager CSS  same file, same hash

No first paint win, then — the gain is a smaller update delta and a halved
entry to parse on weak devices.

Three of the five groups the plan called for are left out, and not silently:
mapbox-gl/maplibre-gl, leaflet and react-markdown already sit in async chunks
whose hashes survive a release, so a group would only rename them. Worse,
without tags: ['$initial'] a group also collects modules that hang behind
React.lazy today and turns its chunk into a static import of the entry —
measured, that put the 2.8 MB GL chunk into the index.html modulepreload and
took eager JS to 4.61 MB, undoing the route split for maps.

Note for the next person to touch this: vite 8 bundles with rolldown, so
rollupOptions is only an alias and both manualChunks and advancedChunks are
deprecated in favour of codeSplitting.groups. A config using the old names
builds green and has no effect.

Also refreshes two comments that still quoted pre-split bundle numbers.
2026-08-07 12:01:43 +02:00
Maurice 057d4ba503 perf(client): pick the viewport branch at the route (#1817)
Every page imported its mobile screen statically and chose while rendering, so
each route chunk carried both trees and the viewport only decided which half
stayed dark. Worse, because ten route chunks shared the mobile tree, rollup
hoisted it into the entry chunk — so every desktop user downloaded the whole
mobile shell before seeing a single page.

The branch now decides the chunk. A phone never fetches the desktop planner, a
desktop never the mobile shell.

  entry chunk        1,243 -> 581 kB   (gzip 349 -> 177)
  TripPlannerPage      914 -> 340 kB
  VacayPage            361 ->  57 kB
  AdminPage            413 -> 225 kB
  SettingsPage         259 -> 155 kB

Splitting inside each page instead was measured and rejected: it leaves the
desktop code in the same module as the switch, so a phone would still download
it and then wait for a second, serial request on top — 914 kB and a waterfall
against 550 kB in parallel.

The pass-through wrapper in each page stays deliberately. check-page-pattern
scans from the default export to the next top-level function and rejects hooks
there; making the desktop component the default export fails it immediately on
dashboard, planner and atlas.

RouteFallback gets a mobile face. It renders above MobileShell, so the --m-*
tokens do not resolve for it and the desktop spinner on bg-surface would be a
foreign white sheet in front of the mobile screen.

The five scattered viewport spot checks are replaced by one contract covering
all eleven routes, which is where the switch now lives.
2026-08-07 11:51:37 +02:00
Maurice 1d94814b57 perf(planner): load the tab panels on demand (#1816)
* refactor(costs): move the pure split maths out of the panel module

splitEqualShares, calculateTicketShares and TicketItem sit next to the payer
maths they belong with. They are pure functions with no module state and no
imports from the panel, so this is a move and nothing else.

The reason it matters: the mobile cost sheet and its model import these two
functions from CostsPanel, and MTripShell is a static import of the trip
planner page. Any attempt to load the costs panel on demand would therefore
pull the whole 97 kB module straight back in through the mobile branch.

* perf(planner): load the tab panels on demand

Each tab panel only mounts while its own tab is active, yet all six shipped in
the planner chunk. The page was 1,203 kB after route splitting — larger than
the entire entry chunk. It is now 914 kB, with the panels in six chunks of
their own (files 99, collab 87, packing 71, costs 62, reservations 32,
todo 31 kB).

The panels also get the error boundaries they never had. fe-error-boundary
covered the maps and the plugin frame but not these, so without them a dead
panel chunk would throw past to the route boundary and take the whole planner
with it. The boundary sits outside the Suspense, since Suspense only owns the
pending promise and a rejected one throws straight past it.

One boundary per panel rather than one around the content area: a shared one
would already be mounted with the visible tab, so switching tabs would swap
the entire planner for the placeholder instead of just the part still loading.

PluginFrame stays static — DayDetailPanel and PlaceInspector import it from
the plan tab anyway, so splitting it here would move nothing. The two import
modals stay static for the same reason, via the mobile sheets.

The wiring tests now await their panel. That includes the ones still passing:
React.lazy caches the resolved module for the whole file, so they were only
green because an earlier test had already pulled it in.
2026-08-07 10:37:10 +02:00
Maurice e5962c22d5 chore(client): tighten the precache ceiling and correct the offline docs (#1815)
* chore(client): tighten the precache ceiling now the entry chunk is split

The 6 MB ceiling was set against the pre-split bundle, where the entry chunk
alone was 5.2 MB. It is now 1.2 MB and the largest precached entry is the
heic-to chunk at 3.0 MB, so the old limit had nothing left to catch.

Worth being explicit about what the limit does: a file above it is dropped
from the manifest and the build still succeeds, printing only that the file
will not be precached. The PWA is then quietly broken offline for that file,
which is why the ceiling has to stay close to the real bundle.

Also records why every route chunk is precached rather than pulled in at
runtime, since that is the reason route splitting did not shrink the install.

* docs(wiki): correct the offline cache table

The table still described an api-data cache holding API responses for 24
hours under NetworkFirst. That cache is gone: API requests are NetworkOnly
because Workbox keys entries by URL and cannot vary them on the session
cookie, so on a shared device one account's data could be served to the next.
Documenting a cache that deliberately does not exist is the wrong way round
for a privacy decision.

Map tiles were listed at 1 000 entries; the actual cap is 12 288 and has to
stay in step with MAX_TILES in tilePrefetcher.ts. The two GL tile caches were
missing altogether, and the precache line now says that it covers every page
rather than just the shell.
2026-08-07 10:25:44 +02:00
Maurice 96e4ff5932 perf(client): load the page chunks on demand (#1812)
* feat(client): retry a failed page chunk once before giving up

A dynamic import fails for two different reasons, and only one of them is
worth a second attempt. If the file is gone because a deploy happened while
the tab was open, nothing but a reload helps and reloadOnceForChunk already
owns that path. If the fetch itself went wrong — hotel wifi, an aborted
request, a service worker handing back a truncated entry — one retry fixes
it.

The retry cannot reuse the original specifier, since Vite rewrites it into a
hashed asset URL at build time; the only usable URL is the one the browser
names in the failure message, and a fresh query on it misses both the HTTP
cache and the Workbox precache.

This is also where a healthy load is acknowledged. Clearing the reload marker
used to sit in main.tsx right after render(), which runs before React commits
and long before any chunk is fetched, so the marker was gone before it was
ever needed.

* perf(client): load the page chunks on demand

Every one of the twenty pages was a static import, so the entry chunk carried
the planner, the journal, the atlas and the vacation planner for anyone who
only opened the dashboard. They now load through lazyWithRetry; the entry
chunk drops from 5,183 kB to 1,243 kB (gzip 1,300 -> 349).

LoginPage stays static on purpose: anyone logged out lands there, and every
other route redirects there first.

The Suspense boundary sits above <Routes> rather than inside ProtectedRoute
so it stays mounted across navigations. react-router runs location updates
inside a transition, which keeps the current page on screen while the next
chunk arrives instead of flashing a spinner on every jump.

The precache is unchanged by design: workbox picks up the new chunks, so the
install size stays where it was (107 -> 178 entries, +31 KiB) and every route
remains available offline. This buys first paint, not install size.
2026-08-07 10:22:51 +02:00
Maurice 39b6d9f786 feat(client): catch render errors instead of blanking the page (#1805)
* feat(client): catch render errors instead of blanking the page

There were no error boundaries anywhere, so one throw during render unmounted the
whole tree and left the user staring at a white page with no way back.

ErrorBoundary holds the state in a class — getDerivedStateFromError has no hook
equivalent — and renders a function component for the visible half, so the panel
can translate normally. TranslationContext isn't exported, so contextType was not
an option anyway.

Two things it does beyond showing a message. A chunk that 404s after a deploy gets
one reload rather than a retry button: React.lazy caches the rejected promise, so
retrying re-throws immediately. And the reload happens once per session, or a
genuinely missing chunk would spin the tab.

The global handlers in main.tsx cover what no boundary reaches — event handlers
and async code. They filter axios errors: the interceptor already redirects on
401/403 and there are 600+ toast.error call sites, so reporting again would mean
double toasts and, for the redirects, loops.

* feat(client): isolate routes, widgets and the shell chrome

The route boundary goes on the single line where all 16 protected routes render
their content, inside MobileShell rather than around it: the navigation survives,
so a broken page is something the user can walk away from instead of a dead end.
The mobile --m-* tokens live on the shell's .m-root too, so a fallback outside it
would render invisible.

key={location.pathname}, not resetKeys — ProtectedRoute is the same component at
the same position for every protected route, so otherwise a failure could follow
the user across a navigation.

The public routes render outside ProtectedRoute and were therefore uncovered,
including /login (where people land when everything else failed) and the two
anonymous share pages. PublicRoute wraps those seven.

The five global widgets and the shell chrome get fallback={null} each: a broken
widget should disappear quietly, and if BottomNav dies the user loses the very
thing they need to leave the route.

* feat(client): isolate the GL maps and the plugin frame

Both map wrappers put the boundary outside their Suspense, not inside: Suspense
handles a pending promise, a rejected one throws straight past it. Falling back to
the Leaflet renderer keeps a usable map rather than showing an error card — the
same downgrade the Suspense fallback already does while loading.

PluginFrame is third-party code rendering in our tree; a throw there should cost
the plugin, not the page. resetKeys={[pluginId]} so switching plugins clears it.

The locale loader had no .catch (TranslationContext.tsx). After a deploy the chunk
can be gone, which produced an unhandled rejection and left the UI silently on the
previous language. It now keeps the strings it has and says so in the console.
2026-08-06 23:42:57 +02:00
Maurice 6c63a9bb60 fix(client): move to react-router 7.18.2 (#1804)
* refactor(client): opt into the v7 router behaviour while still on v6

Turning the behaviour on separately from the version bump keeps the two failure
modes apart: red here means a behaviour change, red on the bump means the import
rewrite. The suite is unchanged either way — 527 files, 11099 tests.

Only the two flags a non-data router understands are set; the rest belong to
createBrowserRouter, which this app doesn't use. Both are applied at all six
router mounts, so the tests exercise the same behaviour as production rather than
validating v6 while prod runs v7.

* fix(client): move to react-router 7.18.2

6.30.4 is affected by GHSA-wrjc-x8rr-h8h6 — open redirect through a backslash in
Link and useNavigate, which this app uses in 41 places — and the 6.x line has no
fix: react-router-dom 6.30.2 to 6.30.4 carries GHSA-jjmj-jmhj-qwj2 with no patched
version at all. Staying is not an option, and 7.0 to 7.17 only swaps it for
GHSA-chx6-hx7r-mcp5.

The package rename comes with it: react-router-dom is a re-export shim in v7, so
using it would only defer the same rewrite to the v8 jump. 77 occurrences across
60 files, all of them import specifiers and vi.mock strings.

The surface is small — useNavigate, useParams, useSearchParams, useLocation,
Routes, Route, Link, Navigate, useMatch, MemoryRouter, BrowserRouter — no data
router, no loaders or actions. Nothing else had to change; the future flags from
the previous commit are simply how v7 behaves, so they come back out.

npm audit still reports GHSA-qwww-vcr4-c8h2 on react-router (RSC mode CSRF, fixed
in 8.3.0). It needs RSC mode, which needs a server; this is a static SPA. Closing
it means the v8 major, which is its own piece of work.
2026-08-06 23:01:58 +02:00
Maurice e606ebd9b9 chore(server): pin the WebSocket envelope, dedupe the rate limiter (#1803)
* chore(nest): register LlmParseModule in AppModule

The module was only in the container because BookingImportModule and
PluginsModule happen to import it, so /api/admin/llm/local depended on those two
keeping that import. Register it where every other module is registered.

* refactor(nest): give the rate limiter one home and one instance

RateLimitService keeps its counters in a Map on the instance, but it was listed
in the providers of auth, oauth, transit and trip-invite, so each of them got its
own. Nothing is broken today — the bucket names in use (login/mfa/forgot/reset,
oauth_*, transit_*, trip_invite) don't overlap — but two modules picking the same
name would have quietly allowed twice the configured attempts.

Move it to nest/common, where it belongs now that four domains use it, and hand
it out through a small module they import. Not @Global: the e2e suites build a
container around one domain module, so a global AppModule never pulled in isn't
there at all.

transit.mcp.ts keeps its own instance on purpose — it keys by userId under
mcp_transit_* while the REST path keys by IP.

* test(ws): pin the wire envelope before touching the transport

Every frame on the socket is flat: {type, tripId, ...payload} out, {type, tripId}
in. Nest's WsAdapter dispatches on message.event and answers {event, data}, so
moving this onto a gateway without a custom serialiser would leave join silently
unhandled — no welcome.socketId to send back as X-Socket-Id, and every client
would then receive its own writes.

Nothing catches that today: the 100+ tests that touch realtime mock src/websocket
and never look at the bytes. WS-032 sends the exact shape the stock adapter would
and asserts the server ignores it.

* docs(nest): drop the parts of the README that no longer exist

strangler.ts, DEFAULT_NEST_PREFIXES/NEST_PREFIXES and tests/parity/ are all gone —
every request goes through Nest now — but the README still walks a new contributor
through adding a prefix and writing a parity test against the Express route.

Also spell out that the 80% gate is an average over src/nest/**, not a floor per
file, so a large untested module can sit behind the well-covered small ones.
2026-08-06 22:44:59 +02:00
Maurice 2b11990063 chore(client): take the GL engines out of the entry chunk (#1801)
* refactor(settings): load the GL map preview on demand

MapViewGL and JourneyMapGL are both behind lazy() so a Leaflet-only install
never downloads the GL engines. MapboxPreview imported mapbox-gl and maplibre-gl
statically, which put them back in the entry chunk and cancelled that out.

Wrap it in lazy()/Suspense on both the desktop tab and the mobile screen. The
entry chunk drops from 7987 kB to 5157 kB (2057 kB to 1292 kB gzipped); the
engines now ship as their own chunk, loaded when a GL provider is picked.

The preview no longer resolves synchronously, so the tests that read it right
after switching provider await it now.

* chore(client): drop unused deps, self-host MuseoModerno, add a bundle baseline

@react-pdf/renderer, react-window and @types/react-window have no importers
anywhere: TripPDF builds HTML for the browser print window and JourneyBookPDF
goes through marked. RegisterPage is dead too, /register renders LoginPage.

MuseoModerno was the last font still coming from the Google CDN through a
render-blocking <link>, while main.tsx already self-hosts Poppins and Geist for
exactly the reason stated in the comment there. Same weights, now bundled.

Add rollup-plugin-visualizer behind `npm run build:analyze` so the splitting work
has numbers to argue with, pin build.target instead of inheriting Vite's default,
and bring the workbox size ceiling down from 10 MB to 6 MB — anything above it is
dropped from the precache manifest silently, which only shows up as a broken
offline app.
2026-08-06 22:25:12 +02:00
Maurice 06f5bc2c86 fix(planner): keep the file-upload callback above the place bail-out (#1800)
PlaceInspector returns null when no place is selected, but the useCallback
for handleFileUpload sat below that return. Clearing the selection therefore
changed the hook count and React threw "Rendered fewer hooks than expected",
which takes the whole tree down since nothing catches it.

Move the callback above the bail-out and read the id through place?.id.
2026-08-06 21:59:30 +02:00
Konstantinos Thermos ab9ca4220a fix(atlas): translate unvisited country names via the locale resolver (#1798) 2026-08-06 16:14:02 +02:00
Maurice 143200cae6 chore(dashboard): remove the 4.0.0 release moment (#1792)
The FourZero show was built as a one-off for the 4.0.0 release and was
always meant to come back out afterwards. It lived in its own folder
plus two marked lines on the dashboard, so removing it is exactly that:
the folder and the two lines.

The beacon carried its own cut-off date of 23 August, so nothing about
this changes what users see between now and then beyond taking it out
early.
2026-08-05 19:59:16 +02:00
Konstantinos Thermos dd138e5293 fix(trips): default a new trip to the user's currency setting (#1790)
The new-trip forms hard-coded EUR and ignored settings.default_currency,
so creating a trip always opened on EUR even with USD set in
Settings > General > Currency. Read default_currency (falling back to
EUR when unset) in both the desktop TripFormModal and the mobile
MNewTripSheet; edit mode still pre-fills the trip's own currency.

Fixes #1784
2026-08-05 18:04:40 +02:00
dependabot[bot] c08530f928 chore(deps): bump undici from 7.28.0 to 7.29.0 (#1780)
Fixes CVE-2026-13697 (HIGH).
2026-08-05 15:34:28 +02:00
Renzo Beux 4b60cf0c0e Update the mobile sheet tests for the shared location helper
- reverse() now receives the UI language
- error assertions follow the translated journey.editor.* strings
  instead of the raw error.message and the generic common.error label
- a late error callback after a resolved one-shot fix is asserted to be
  a no-op, since a settled promise cannot reject anymore
- add tests for the new location button: reverse-geocoded fill and the
  translated inline denial message
2026-08-05 15:28:20 +02:00
Renzo Beux a66f70031c Wire the current-location button into the mobile entry sheet
- MJourneyEntrySheet renders the same crosshair button in its location
  field, with the coordinate fallback and reverse-geocode race guard
- Quick-capture auto-locate now goes through getCurrentPositionOnce(),
  replacing the raw navigator.geolocation call and the untranslated
  error.message it surfaced
- Error code to i18n key mapping extracted to geoOnceErrorKey(), shared
  by both editors
- Test for the race guard: a search result picked while the reverse
  geocode is in flight must survive
2026-08-05 15:28:20 +02:00
Renzo Beux 3ec4b33dbb Add "use my current location" button to Journey entry editor
- One-shot getCurrentPositionOnce() helper in useGeolocation with typed
  error codes (unsupported, insecure context, permission denied, timeout)
- Button inside the entry editor Location field fills coordinates from the
  device and reverse-geocodes the place name via /api/maps/reverse,
  falling back to formatted coordinates when no name is available
- New journey.editor.* i18n keys in all 23 locales
- Unit tests for the helper and component tests for the button flow
2026-08-05 15:28:20 +02:00
Renzo Beux 8a59c139c0 Sync the runtime SDK types with colours and note mobile tints only the badge 2026-08-05 15:28:15 +02:00
Renzo Beux f718240ffc Let a dayTintProvider send its own colour
Four tones cannot keep a twenty-stop trip's legs apart, which is most of
the point of colour-coding one. A contribution can now carry `#rrggbb`
alongside `tone`, per region or as a shorthand.

The split of control is: the plugin picks the hue, the host picks the
weight. A colour rides the same per-theme, per-region alphas as a tone,
and its OKLCH lightness is clamped into a per-theme band first, so no
contribution can hand a user a day card they can't read. The band
brackets the built-in tones rather than tightening around them, so an
ordinary colour passes through untouched and a hex equal to a tone
renders exactly like that tone. Hue and chroma are never touched - it
bounds a colour, it doesn't boost one, so a near-white grey still lands
as a faint tint.

Only `#rrggbb` survives normalization, and that is a security boundary
rather than tidying: the value ends up inside a `color-mix()` in an
inline `background`, a shorthand that takes a layer list, so a string
closing the paren early could append a `url(...)` and beacon everyone
viewing the trip.

A region gets exactly one paint instruction - what it names beats the
shorthand, a colour beats a tone at the same level - so the client never
has a tie to break. A region naming a colour the host can't use degrades
to `default` like a bogus tone already did.
2026-08-05 15:28:15 +02:00
Renzo Beux c89d2bf3a1 Add a dayTintProvider plugin hook
Lets a plugin colour-code days in the Plan sidebar. trip-segments can
already announce "Kanazawa begins" in the day plan, but it can't make
day 12 look like it belongs to Kanazawa.

dayScheduleProvider can't cover this: it renders a fixed row, and it's
capped at 60 items per provider, so one item per day would tint the
first 60 days of a six-month trip and silently stop. This hook is
bounded by day count instead.

GET /api/day-tints/:tripId returns per-day tones for three regions of a
day card - the number badge (also the mobile day chip), the header row,
and the expanded activity list. `tone` is a shorthand filling every
region a contribution doesn't name; unnamed regions render as they do
today. Tones only, never hex, so the host can pick the alpha per theme
and per region.

One contribution per day, resolved whole: first entry wins within a
provider, first granted provider wins across plugins.
2026-08-05 15:28:15 +02:00
Konstantinos Thermos 28ffc814c0 fix(llm): retry OpenAI import with max_completion_tokens
Newer OpenAI models (gpt-5.x) reject max_tokens with a 400 and demand
max_completion_tokens, so AI booking import failed and no reservations
were created. Send max_tokens first (understood by the classic chat API
and every local server), and only on a 400 naming max_completion_tokens
resend the whole request with that parameter instead — mirroring the
existing json_schema->json_object fallback beside it. Detection is by
API response, not a model-name allowlist.

Fixes #1760
2026-08-05 15:13:17 +02:00
Konstantinos Thermos 17dd6b7caa feat(map): toggle the trip map between default and satellite tiles
Add a corner button on the Leaflet trip map that switches the base layer
between the default street tiles and a tokenless satellite view (ESRI
World Imagery). The choice is a per-user setting (map_base_layer) that
persists and applies instantly, including offline. GL-renderer satellite
styles are unchanged.

Closes #993
2026-08-05 15:13:13 +02:00
Konstantinos Thermos 5315bacc2a fix(costs): save a picked payer when nobody splits the expense
The web expense modal only sent the payer when at least one split
participant was selected, so a personal expense (e.g. a flight paid by
one person, not shared) was stored with an empty payers[] and lost its
payment attribution. Drop the participants.size guard so a picked payer
always goes out, matching MCostSheet. "Nobody paid" stays expressible via
the explicit "No one paid yet" option (payerId 0); the #1286 test is
updated to use it.

Fixes #1766
2026-08-05 15:11:44 +02:00
Konstantinos Thermos 7da4cad086 fix(admin): stop a stale base URL from hijacking the Anthropic endpoint (#1777)
The AI Parsing admin panel kept the Base URL in state after switching provider
and saved it unconditionally, so moving a configured Local/OpenAI provider to
Anthropic left the old host in the stored config and misrouted every Anthropic
request away from api.anthropic.com. Clear baseUrl on save when the provider is
Anthropic, matching the per-user LlmConnectionSection behaviour.

Fixes #1748
2026-08-04 13:18:12 +02:00
Maurice 446bbdc38f fix(client): stop shared items counting towards the recipient bag weight
An item shared through the sharing tier is visible to its recipients but
carried by its owner. The bag totals filtered on bag_id alone, so every
recipient saw the owner's weight added to their own load — which is exactly
the situation that tier exists for (one person brings the first-aid kit).

Filtering by owner_id, as the issue suggests, would have broken more than it
fixed: owner_id is stamped on every item including Common ones, so the group
pool would have shrunk to "only what I entered myself" even on trips where
nobody shares anything. The predicate keeps the Common pool intact and drops
only private items owned by someone else.

Closes #1767
2026-08-03 21:59:34 +02:00
Maurice d7d7fac339 feat(i18n): translate the bag weight limit strings 2026-08-03 21:40:02 +02:00
Maurice e25a1276dc feat(client): let people actually type a bag weight limit
The column, the contract, the API route and its tests have all been there
since v2.9.0 — there was simply no field to enter a limit in, so the only
way to record one was to put it in the bag name, where nothing can act on
it. Both the desktop bag card and the mobile bag row now take a limit in
kilograms (how airlines state them) and store it in grams. Emptying the
field clears the limit instead of writing a zero.

While adding it: the fill-bar formula existed in three places, and the one
in the bag modal had been copied without the limit, so a limit set on a
window narrower than xl did nothing. It now lives once in
packingListPanel.helpers as bagFillPct, next to itemWeight.
2026-08-03 21:40:02 +02:00
Maurice 628f4e78cd fix(server): let a copied packing item keep its weight
createItem never accepted weight_grams or bag_id — not in its signature, not
in the INSERT — so cloneItem had nothing to pass on and every copy arrived
empty. For a group trip that meant re-entering every weight by hand for each
traveller, which is what people were doing.

The bag needs more care than the weight. Bags belong to a trip, but they can
be assigned to a person, so inheriting the original bag would drop your copy
into somebody else luggage and inflate their total. A copy keeps the bag only
when nobody owns it or when the copier is one of its members.
2026-08-03 21:40:02 +02:00
Maurice 4e090411fe fix(server): stop a failed notification import from crashing the run
The fire-and-forget notification sends guard the send() call but not the
dynamic import around it, so a rejected import escapes as an unhandled
rejection. In CI that surfaced as an EnvironmentTeardownError from vacay
after the suite had already passed, failing the run with every test green.

trips, packing and collections already catch on the import chain; collab and
the two vacay sends were missing it.
2026-08-03 20:21:18 +02:00
Maurice 8ab50dcbbb docs(wiki): explain visited versus planned countries in the atlas
The page still described the old behaviour, where every trip counted as a
visit regardless of its dates.
2026-08-03 20:21:18 +02:00
Maurice 6019b3ed4e feat(client): carry the planned/visited split into the mobile atlas
The mobile screen shares useAtlas, so it inherits the switch state; it gets
its own compact toggle next to the bucket-list button. Search now labels a
planned country as such instead of calling it visited, the stats card hangs
the planned count off the country total, and the suggestion list keeps
places you have actually been at the top.
2026-08-03 20:21:18 +02:00
Maurice 558f348881 feat(client): let the atlas hide countries you have not reached yet
Planned countries are off the map by default and come back through a switch
above the globe, drawn dashed so they never read as somewhere you have been.
The switch only appears once there is something planned to show, and it
remembers its state.

Three things that needed care:

The colour palette is built from the visited countries alone. It assigns
colours by index, so letting planned ones join would reshuffle the whole map
every time the switch is flipped.

countryMap decides both the tooltip and what a click does, so it is built
from the visible countries — a hidden country has to fall through to the
mark-as-visited branch or it becomes unclickable.

The region layer read `data` without listing it as a dependency, which meant
region colours already went stale on their own; adding the switch would have
made that visible.

Five copies of the optimistic mark-as-visited update have moved into
withCountryMarkedVisited, which also promotes a planned country instead of
adding it twice. Antarctica joins the continent row when someone has been.
2026-08-03 20:21:18 +02:00
Maurice 97f6a43333 feat(i18n): translate the planned-countries strings
Also adds atlas.antarctica, which CONTINENT_MAP has always known about but
no locale ever had a word for.
2026-08-03 20:21:18 +02:00
Maurice d9778ed7c0 fix(server): keep the passport card in step with the atlas
getTravelStats derives its countries independently of the atlas service, so
without the same date filter the dashboard would have kept counting trips
the map no longer treats as visited — the drift #1490 closed.

Manually marked countries and the hidden-country tombstones are untouched.
2026-08-03 20:21:18 +02:00
Maurice 269bd9140f feat(server): count only started trips as visited in the atlas
Every country now carries the status of the strongest source it came from:
a country reached by both a past and a future trip is visited, one that only
appears in upcoming trips is planned. Marking a country by hand still wins
over everything, since that is a statement of fact rather than a date.

totalCountries drops to the visited ones and totalCountriesPlanned sits
beside it; continents and mostVisited follow the same rule. countries[]
stays a superset of what it returned before, so an older client keeps
painting the map exactly as it did.

The endpoint query has to select trip_id to know which trip a leg belongs
to, which makes its DISTINCT per trip — the coordinates are collapsed again
in JS so the point-in-polygon lookup still runs once per location.
2026-08-03 20:21:18 +02:00
Maurice ddba45ab3e feat(shared): tell visited countries apart from planned ones
The atlas counted every trip as a visit, so a country you had merely booked
a flight to was stamped the same as one you came back from.

tripVisitStatus buckets a trip by its dates: already started (or running)
counts as visited, starting in the future is planned, and a trip with no
dates at all is an idea that must not reach the visit stats. The comparison
is UTC, matching the dashboard and journey lifecycles.

Requested in discussion #1048.
2026-08-03 20:21:18 +02:00
jubnl c42aea4171 Merge remote-tracking branch 'origin/dev' into dev 2026-08-02 15:52:22 +02:00
jubnl 414d967223 docs: regenerate migration-graph after the admin fold
Records the fold and corrects two long-carried errors: there is no admin MCP
surface (the '11 MCP consumers' figure predated the Phase-0 addons extraction)
and systemNotices/conditions.ts was never a consumer. The memories/admin edge is
documented in the right direction for the first time. journeyService and
oauthService now head the frontier.
2026-08-02 15:08:02 +02:00
jubnl 2ac6c76086 chore: move admin backend to proper DI
The 851-line services/adminService folds into the wrapper AdminService over
DatabaseService plus injected Settings/Addons/Passkey/Packing/Auth/Permissions/
Notifications services; the auth, notifications and permissions bridge imports
all become injections, PERMISSION_ACTIONS stays a plain const, and the
mcp/sessionManager deep import keeps its anti-cycle comment. Ahead of the fold
the 11 packing-template functions move to PackingService, which already owned all
three template tables — resolving the admin-2 residual with no bridge. The pure
and module-scoped half moves to admin.helpers.ts, the version cache staying
module-scoped so admin.bridge's instance and the container singleton share one
GitHub fetch. A 1-export admin.bridge.ts serves scheduler.ts's cron, the only
out-of-container consumer. Recipe steps 3 and 4 are no-ops: no src/mcp/tools/
admin.ts has ever existed and the plugin host never imported the domain.

Four lines are non-verbatim, all path re-anchoring one directory deeper, both
resolved paths verified against the emitted dist layout.

admin.dto.ts clears all twenty AdminController allow-list entries, retiring the
three 'enabled must be a boolean' checks plus 'permissions object required' and
'Object body required' for the pipe envelope. The module e2e goes DI-native: the
3-method whole-module mock dies, 6 cases become 15 over real SQL.

Quirk fixes on top, each pinned: the three places toggles go fail-closed with an
append-only backfill migration; the template item routes honour :templateId;
updateUser rejects an empty username/email; createInvite 404s an unresolvable
trip_id; listOAuthSessions guards its scopes parse; GitHub fetches gain a timeout
and size cap and failures cache briefly; updateOidcSettings/updateAddon/updateUser
become transactional; admin.oauth_session.revoke is renamed to match its
siblings; two mutations gain their missing audit rows; getAuditLog drops the
_parse_error sentinel.

Tests move with IDs preserved (ADMIN-SVC-001..069 incl. the pre-existing 029/030
gap and duplicated 069, VNOTIF-001..007), plus ADMIN-BR-001 and ADMIN-SVC-070..076.
2026-08-02 15:07:55 +02:00
jubnl ee13b6325b feat(shared): extend the admin request contracts
Twelve schemas covering every AdminController body, replacing the four that were
imported nowhere. Deliberately permissive wherever AdminService already owns a
bespoke 400 ('Invalid role', 'Name is required', 'Username, email and password
are required'), so those errors survive byte-identically: role is z.string() not
an enum, template name and the user-create fields stay optional. savePermissions
keeps z.unknown() values so unknown levels still land in the 200 response's
skipped list; addon config and the notification-preference matrix stay records so
per-addon keys and plugin channels are not stripped; devTestNotification's inApp
accepts the object form the dev panel actually sends.
2026-08-02 15:07:23 +02:00
Johann Bauer ad7d81fd00 fix(i18n): use mode-agnostic German departure translation (#1765)
'Abflug'/'Abflugzeit' are flight-specific, but the reservations.departureDate/
departureTime keys are shared across all transport types in TransportModal
(flight and train waypoints). Switched to 'Abreise'/'Abreisezeit', which read
naturally for any mode of transport.
2026-08-02 14:44:15 +02:00
jubnl aec2d8cd87 docs: regenerate migration-graph after the notifications fold
Import-scan regeneration: notificationService and the folded
inAppNotifications leave the graph, the notifications cluster is consumed as
plain infra by the DI service, and the unblock cascade over-delivered —
repointing memories' sends erased that cluster's last domain edge, so BOTH
adminService and journeyService join the ready frontier (adminService heads
the order). Adds the dated correction bullet and the 'Quirks fixed after the
notifications fold' section (fixed vs deliberately-preserved).
2026-08-02 11:36:46 +02:00
jubnl 00447c9043 feat(server): validate notification request bodies
notifications.dto.ts wraps the @trek/shared request schemas via createZodDto
and types all five @Body() params; the five NotificationsController entries
leave the body-contract allow-list (ratchet gate enforced). Contract catch:
the client sends server/token: null on test-ntfy to mean 'use the saved
value', so testNtfyRequestSchema gains .nullable() there (spec pinned). The
inline response-enum and url-type checks die — malformed bodies now get the
pipe's standard envelope; valid bodies behave byte-identically. Two
integration cases pinning pre-ratchet tolerances (flat notify_* preferences
body, body-less test-smtp POST) update to the current wire contract.
2026-08-02 11:36:36 +02:00
jubnl d203d8cccd chore: move notifications backend to proper DI
The send() dispatcher (services/notificationService.ts) and the in-app store
(services/inAppNotifications.ts) fold together into the DI-native
NotificationsService over DatabaseService + RealtimeService; the prefs
matrix, transports, channel registry and inAppNotificationActions stay plain
infra modules. A 1-export notifications.bridge covers scheduler, legacy
adminService/memories and the six lazy fire-and-forget sends; AdminController
and the plugin RPC host (24th factory dep) inject. The 5-tool registrar +
notifications-in-app resource move to notifications.mcp.ts. Tests move with
case IDs preserved (NSVC-*, INOTIF-*, + NSVC-020 bridge pin); ~18 suites
repoint path mocks/warm-ups; the module e2e goes DI-native.

Also carries the verified-quirk fixes that would normally trail as a
separate fix(server) commit (file-overlap with the fold): toUtcIso on all
three created_at paths (INOTIF-013/014), respond claims the response CAS
before running the action handler with release-on-failure (INOTIF-011/012),
synology's fire-and-forget send gains .catch, the dispatch-failure log
unwraps Error messages (NSVC-021), and the MCP resource description drops
its false 'unread first' claim.
2026-08-02 11:36:06 +02:00
jubnl c814653f4e fix(server): tidy passkey quirks after the DI fold 2026-08-02 10:28:22 +02:00
jubnl 74be789f22 docs: regenerate migration-graph after the passkey fold 2026-08-02 10:20:38 +02:00
jubnl 5d4b2abd26 feat(server): validate passkey request bodies 2026-08-02 10:16:10 +02:00
jubnl e65f851446 chore: move passkey backend to proper DI 2026-08-02 10:10:33 +02:00
jubnl f40c66da6b fix(server): tidy oidc quirks after the DI fold 2026-08-01 18:36:07 +02:00
jubnl 5da98d0fb9 docs: regenerate migration-graph after the oidc fold 2026-08-01 18:25:51 +02:00
jubnl ec0709741a chore: move oidc backend to proper DI 2026-08-01 18:25:28 +02:00
jubnl d892b61a59 fix(server): tidy auth quirks after the DI fold
Verified defects the relocation carried byte-for-byte, each pinned by a
regression test (AUTH-DB-089..093):

- getTravelStats kept dropping lat/lng of exactly 0 (equator / prime
  meridian) through its falsy check — now explicit != null.
- registerUser's INSERT user -> invite used_count bump -> joinTripAsMember
  ran statement-by-statement; the whole signup now runs in
  db.transaction() so a mid-sequence throw rolls everything back.
- verifyMfaLogin's backup-code burn and last_login/login_count bump now
  commit as one atomic pair.
- deleteMcpToken's revokeUserSessions call is best-effort (try/catch),
  matching the changePassword/resetPassword revocations.
- updateApiKeys' current! non-null assertions became current?. ?? null —
  a user row deleted mid-request degrades to a 0-row UPDATE, not a
  TypeError/500.

Deliberately preserved (documented in migration-graph.md): the two
divergent "Password authentication is disabled" strings and enableMfa's
15-min pending-secret TTL after a wrong code.
2026-08-01 17:46:54 +02:00
jubnl 9bc31e6a1c docs: regenerate migration-graph after the auth fold
Re-ran the three-pattern import scan over server/src: authService row
deleted, thirteen helper rows updated (nine lose their last legacy
importer), oidcService/passkeyService join the ready frontier as
auth.bridge repoints, adminService is blocked on notificationService
alone, atlas.bridge's death recorded, dependency-honest order step 5
ticked, a post-auth-fold corrections entry added (bridge tax held exactly;
the reverse cycle-break, the in-container module fan-out and path-mock rot
are now tracked shapes), and the preserved auth quirks listed as trailing
fix(server) candidates.
2026-08-01 17:40:25 +02:00
jubnl fb87202683 feat(server): validate auth request bodies
DTO ratchet for the auth domain (collections sibling-commit precedent):

- shared/src/auth/auth.schema.ts gains the six missing request schemas —
  mapsKey (nullable: the client clears with an explicit null), apiKeys and
  settings (per-key partials, nullable key values), appSettings (open
  record — the server's ADMIN_SETTINGS_KEYS allow-list and its bespoke
  lockout/self-MFA 400s stay service-owned), mfaDisable, resourceToken
  (the 'Invalid purpose' 400 stays service-owned) — plus spec cases.
- New nest/auth/auth.dto.ts createZodDto wrappers; all 14 raw @Body()
  params typed (9 AuthController + 5 AuthPublicController), validated by
  the global ZodValidationPipe.
- All 14 auth entries removed from body-contract-allow-list.ts
  (PasskeyController's 4 stay until passkeyService migrates).
- auth.e2e mirrors the production pipe and pins the { error: 'field: ...' }
  envelope on a shapeless login body; auth.controller.ts drops its
  duplicated avatarDir const in favour of the auth.helpers export.
2026-08-01 17:40:16 +02:00
jubnl 45564b0a8a chore: move auth backend to proper DI
Fold the 1497-line services/authService.ts into the DI-native nest/auth
domain (Wave-5 chain opener, unblocked by the atlas fold):

- Pure crypto half (backup-code hash/match/generate, stripUserForClient,
  key masking, the import-time DUMMY_PASSWORD_HASH timing equaliser and
  avatarDir mkdir — documented parity exceptions) moves verbatim to the
  plain module nest/auth/auth.helpers.ts.
- DB half folds into AuthService over DatabaseService + injected
  PermissionsService (ex permissions.bridge) + AtlasService (ex
  atlas.bridge, deleted on schedule). mfaSetupPending and the per-email
  reset throttle stay module-scoped so the bridge and container instances
  share them; avatarDir and require('../../../package.json') re-anchor one
  directory deeper — with the two injection swaps, the only non-verbatim
  lines. Controller-facing method surface unchanged.
- New 8-export auth.bridge.ts for out-of-container consumers: mcp/index.ts
  token verification, the journey/notifications/transports registrars'
  isDemoUser, and the still-legacy adminService/oidcService/passkeyService.
- 15 domain *.mcp.ts demo guards now inject AuthService (their modules
  import AuthModule); atlas.mcp.ts alone stays on the bridge because
  AuthService injects AtlasService and the reverse module edge would close
  an AuthModule<->AtlasModule cycle (places.mcp precedent). OidcService and
  PasskeyEnabledGuard inject AuthService too.
- Tests move with case IDs preserved: authService.test.ts ->
  auth.helpers.test.ts, authServiceDb.test.ts -> auth.service.test.ts
  (+ AUTH-BR-001..007 bridge delegation, + AUTH-DB-050..088 pinning the
  previously untested getAppConfig/login/MFA/reset/admin-settings branches
  now under the src/nest coverage gate). auth.e2e converts DI-native (real
  bcrypt, real audit rows); oidc.e2e swaps its dead path mock for an
  instance spy; websocket/integration suites repoint to bridge/helpers.

Quirks preserved byte-for-byte (0-coord drop in getTravelStats,
un-transactioned registerUser/verifyMfaLogin multi-writes, unguarded
revokeUserSessions in deleteMcpToken, current! assertions in
updateApiKeys) — fixes land separately.
2026-08-01 17:39:59 +02:00
jubnl 464fe5ff77 docs: regenerate migration-graph after the atlas fold 2026-08-01 15:50:46 +02:00
jubnl ae3ba18b9e chore: move atlas backend to proper DI
Fold the 1612-line services/atlasService.ts into the DI-native AtlasService:
the stats aggregation, visited countries/regions with the #1490 tombstone and
cascade logic, and the bucket-list CRUD become methods over the injected
DatabaseService. The ~750-line pure-geo half — the bundled admin0/admin1
boundary stores with their #1576 streaming builders, the point-in-polygon
indexes, Nominatim geocoding with its shared throttle, and every module-scoped
cache including the import-time unref'd cleanup interval — moves verbatim to
the plain module atlas-geo.ts so the caches stay process-global across the
container instance, the bridge instance and test helpers (assetPath
re-anchored one directory deeper, the only non-verbatim line).

The 10-tool mcp/tools/atlas.ts registrar plus all four atlas resources in
mcp/resources.ts move onto the decorator registry as atlas.mcp.ts — the when:
atlas-addon gate is parity here (legacy tools and resources both gated; the
REST controller deliberately does not). resources.test.ts retires with its
last two cases, which move to tools-atlas-expanded.test.ts. The plugin RPC
host swaps its 9 atlas imports for the injected AtlasService (23rd constructor
dep); a minimal 2-export atlas.bridge.ts serves the one legacy consumer,
authService.getTravelStats, and dies when authService migrates.

Adopts atlas.dto.ts over the existing shared schemas and clears all three
AtlasController entries from body-contract-allow-list.ts: the hand-rolled
'name and country_code are required' 400 becomes the ZodValidationPipe
envelope (todo/places trade) while the whitespace-only bucket name keeps its
legacy 'Name is required' trim guard.

Fixes the verified defects the relocation had faithfully carried, each with a
regression test (ATLAS-SVC-031..036 plus two MCP casing cases): the four
multi-statement mark/unmark writes now run in db.transaction() (the region
cascade nests as a savepoint), the trip-less countryPlaces early return
honours manually_marked, the '|| null' bindings that dropped lat/lng 0 and
empty-string notes on bucket update become '?? null', the mutating bucket SQL
is user-scoped, and the MCP region/country-places tools uppercase their codes
to match REST.

Quirks deliberately preserved: the detached GET-path place_regions backfill
writes, getStats' two divergent return shapes, the import-time cache-cleanup
interval, getCountryFromAddress's unvalidated bare 2-letter codes, and the
whitespace-only-name silent no-op on bucket update.

Tests move with their case IDs: atlasService.test.ts becomes
unit/nest/atlas.service.test.ts (pure-geo imports from atlas-geo, DB calls
through the constructed service, plus bridge-delegation cases), the atlas e2e
converts to the DI-native pattern (real SQL against the full temp-db schema),
and the plugin-host path mock becomes a constructor stub.
2026-08-01 15:50:36 +02:00
jubnl c78134831f fix(server): tidy collections quirks after the DI fold 2026-08-01 14:03:05 +02:00
jubnl 79c20c07c8 docs: regenerate migration-graph after the collections fold 2026-08-01 13:54:36 +02:00
jubnl d11b7bb054 feat(server): validate collections reorder and delete-many bodies 2026-08-01 13:54:36 +02:00
jubnl cab49ea511 chore: move collections backend to proper DI 2026-08-01 13:54:19 +02:00
jubnl 4f8300e2ba fix(client): fall back to scheduled stop times in transit reservations 2026-08-01 12:49:22 +02:00
jubnl 2b03f9a024 fix(server): tidy transit itinerary quirks after the DI relocation 2026-08-01 12:49:22 +02:00
jubnl e80ae79b77 chore: move transitItineraryService into nest/transit as pure helpers 2026-08-01 12:45:35 +02:00
jubnl 57cc676e8a test(uploads): pin the uploads subdir list across Dockerfile and boot
The set of uploads subdirectories is hand-mirrored between the image build
and the boot-time mkdir in server/src/index.ts, and drift is invisible
until a user whose bind-mounted uploads dir is not writable by node hits an
EACCES on the first upload to the missing dir (#1762).

Parse both lists and assert they match, with a guard case so the regexes
cannot silently match nothing and pass vacuously.
2026-07-31 15:41:02 +02:00
jubnl 1fb6d19f6f fix(uploads): pre-create journey and places upload dirs
Both were created lazily on the first upload, which needs write permission
on the uploads mount point itself at request time. On hosts whose
bind-mounted uploads dir is not writable by the runtime user, every feature
writing into an already-existing subdir keeps working while journey photo
and place image uploads fail with EACCES (#1762). Creating them in the
image and at boot turns that into one loud startup failure instead of a
stray 500 on upload.

Also drop the recursive chown over all of /app from the final layer: it
copied up every inode it touched and duplicated node_modules, dist and the
client bundle into a 518 MB layer. The chown now rides the layer that
installs node_modules, later copies carry --chown=node:node, and the last
layer chowns only the paths it creates. Everything under /app stays
node-owned; image size 2.36 GB -> 1.68 GB.
2026-07-31 15:40:24 +02:00
jubnl f02f255016 fix(places): cap the Naver list-import response like the Google one
The place fold capped the Google list-import body at 8 MB but left the Naver
importer reading its pages unbounded — same attacker-influenced URL (the folder
id), and worse in practice because that path is a pager that buffers a fresh
body on every iteration.

fetchPage now checks the declared content-length before reading and the actual
length after, returning the existing 'Failed to fetch list from Naver Maps' 502
on either. Reading the body as text and parsing it explicitly (rather than
apiRes.json()) is what makes the post-read check possible; the malformed-payload
branch keeps its 'Invalid list data received from Naver Maps' 400 because the
JSON.parse stays inside the same try.

The post-read check is the one that matters for a hostile server: a chunked
response carries no content-length, so the declared check alone is evadable.
Covered for both providers now — PLACE-SVC-073b (Google, chunked) and
PLACE-SVC-074..077 (Naver: declared, chunked, malformed, happy path).

The five Naver fetch mocks in the integration suite move from json() to text().
2026-07-30 17:53:48 +02:00
jubnl 4f0f80ad69 chore: move place backend to proper DI
Fold the 1029-line services/placeService.ts into the DI-native PlacesService:
the CRUD + ratings SQL, the GPX/KML/KMZ importers and the Google/Naver list
importers become methods over the injected DatabaseService, byte-identical
statements, COALESCE semantics and the If-Match conflict protocol (#1135).
The pure half — frozen XML parsers, the KMZ unpacker, the dedup predicates,
the Google hex-id parsers, reclaimPhotoCache — moves to places.helpers.ts
(maps.helpers precedent).

The 10-tool mcp/tools/places.ts registrar plus the trek://trips/{tripId}/places
resource move onto the decorator registry as places.mcp.ts. search_place travels
with them because its gate is places:read, not the read-only geo group, and now
injects MapsService. TripsService, DaysMcp, BookingImportService and the plugin
RPC host (PluginHostDepsFactory's 21st constructor dep) all inject PlacesService,
so the domain needs no bridge at all. The two assignments.bridge imports that
places.mcp.ts keeps are a deliberate cycle break: AssignmentsModule imports
DaysModule and DaysModule now imports PlacesModule for days.mcp.ts's place
creation, so injecting AssignmentsService there would close
DaysModule -> PlacesModule -> AssignmentsModule -> DaysModule. Same seam
reservations.mcp.ts uses, same trade trips.bridge.ts documents.

Also folds services/placeEnrichment.ts in — its DB/websocket/Maps half becomes
PlacesService methods over the injected DatabaseService/RealtimeService/
MapsService, its pure match selector joins places.helpers.ts — which retires
nest/maps/maps.bridge.ts with its last consumer.

Adopts places.dto.ts for the seven grandfathered body contracts and clears every
PlacesController entry from body-contract-allow-list.ts. Two consequences worth
knowing: the bespoke 400s 'Place name is required', 'ids must be an array of
numbers' and 'URL is required' are now the ZodValidationPipe envelope, and
because a pipe runs before the handler a malformed body 400s ahead of the
trip-access 404 it used to follow — the same trade the todo and trips migrations
took. placeBulkUpdateRequestSchema.ids drops .min(1) so the endpoint's
empty-list short-circuit stays reachable.

Fixes four defects the relocation had faithfully carried, each with a
failing-first regression test:

- create dropped lat/lng of exactly 0, so a place on the equator or the prime
  meridian lost its coordinates (`x || null` cannot tell absent from zero).
- duration_minutes: 0 was unsettable — replaced by the 60-minute default on
  create, and read as "absent" by COALESCE on update.
- the journey delete hook fired on unscoped ids: DELETE /:id, POST /bulk-delete
  and the delete_place MCP tool could detach another trip's journey entries for
  an id they then refused. All three scope first via the new
  PlacesService.scopedIds, mirroring the guard PluginHostDepsFactory already had.
  bulk_delete_places also moves its hook ahead of the DELETE —
  journey_entries.source_place_id is ON DELETE SET NULL, so running it afterwards
  left the entries as orphans.
- the place search treated % and _ as LIKE wildcards; a bare % returned the whole
  trip. The three clauses now carry ESCAPE with the term escaped.

Plus hardening on the Google list fetch: the response is capped at 8 MB
(declared content-length and post-read length, the transit.service precedent)
and its JSON.parse is guarded, so a malformed provider payload produces the
existing 'Invalid list data received from Google Maps' 400 instead of throwing.

Quirks deliberately preserved: the non-COALESCE route_color (#776),
currency/transport_mode staying unclearable, importGpx returning null for a file
that parses but yields no usable geometry, update skipping the If-Match check
when the stored updated_at is null, and every string-valued `x || null`.

Tests move with their case IDs: placeService.test.ts and the wrapper suite merge
into unit/nest/places.service.test.ts, kmzUnpack into places.helpers.test.ts,
placeEnrichment into places.enrichment.test.ts. The places e2e converts to the
DI-native pattern (real SQL against temp-db DDL), tools-places.test.ts is kept
and extended so it exercises the decorator path through the test registry, and
the plugin-host and booking-import path mocks become constructor stubs.
2026-07-30 17:07:12 +02:00
xthephreakx 57017b8aed fix(navbar): prevent centred tab pill overlapping actions on tablet (#1747)
The desktop navbar's centred tab pill is absolutely positioned, so on the
md-range (tablet, ~768-1024px) its right edge slid underneath the right-hand
action cluster (notification bell + user menu) once the tab text labels made it
wide enough.

Hide the tab text labels below the lg breakpoint (icon-only on tablet, icon +
label on desktop ≥1024px), which keeps the pill narrow enough to clear the
actions. The icons gain title/aria-label so they stay accessible without text.

Co-authored-by: phreaky <phreakydev@gmail.com>
2026-07-29 22:09:40 +02:00
jubnl cd67074e0b Merge remote-tracking branch 'origin/dev' into dev 2026-07-29 21:40:35 +02:00
jubnl 4e0ac74fcb chore(deps): bump @modelcontextprotocol/sdk to ^1.30.0
Security fixes in the MCP SDK.
2026-07-29 21:40:05 +02:00
jubnl c7760cb823 docs: regenerate the legacy-services dependency graph after the transit fold 2026-07-29 21:40:05 +02:00
jubnl f4bf7cf3dc chore: move transit backend to proper DI
Includes the quirk-repair pass (upstream timeout + size cap, LRU cache, NaN-duration guard) that maps landed as a sibling fix commit.
2026-07-29 21:39:51 +02:00
Maurice 129f952db4 fix(pdf): stop a flowing day stranding its header at the foot of a page
Keeping a stay whole moved its cards to the next sheet, and the header bar
stayed behind on the old one — empty, above a stretch of white, with the
same day printed again overhead. The repeat itself is the continuation
marker from #1471; what was wrong is that there was nothing left to
continue from.

break-after on the thead and break-before on the tbody are both ignored by
Chromium here, measured rather than assumed. Holding the whole day together
is what works: a day that fits moves as one, and a day too long for a page
still breaks and still repeats its header. Scoped to the flowing layout,
since a day that starts its own page can never strand one.
2026-07-29 21:32:39 +02:00
Maurice eb817dea65 fix(pdf): keep a stay whole across a page edge
An accommodation could be cut in half by a page break — the check-in time
at the foot of one sheet, the hotel name and address at the head of the
next, under a repeated day header that read like a duplicate day. Place and
note cards have carried break-inside: avoid all along; this one never did.

It could always happen to a day that overflowed its page. Letting the days
flow means content meets a page edge far more often, which is how it turned
up. Printing the same trip through Chromium: two torn stays before, none
after.
2026-07-29 21:32:39 +02:00
Maurice 5dbb2db3ee fix(planner): keep the PDF tooltip inside the window
The export button is the one toolbar button with a hand-rolled tooltip
instead of the shared one. It was anchored with `right: 0` and never
wrapped, so its label grew leftwards out of the day plan — the leftmost
pane — and off the window.

The shared tooltip is portalled and clamped to the viewport, which is what
every neighbouring button already uses. The hover state it needed was
threaded down from DayPlanSidebar and has no other reader, so it goes too.
2026-07-29 21:32:39 +02:00
Maurice be5b48532d fix(pdf): dress the layout switch like the rest of the app
The preview shipped a bare checkbox, which is not what a toggle looks like
anywhere else in TREK. The overlay is imperative DOM, so the switch from
Settings/ToggleSwitch cannot be rendered into it — as static markup it would
carry no behaviour — but its geometry and its tokens can be, and the control
now reads as the same one.
2026-07-29 21:32:39 +02:00
Maurice 03be438e0b feat(pdf): make the page break between days optional
Every day started a new page, so a plan of short days printed one sheet per
handful of lines and looked nothing like the view it was exported from.

The preview now carries the choice, next to Save as PDF, and remembers it.
The two layouts differ by a single class on <body>, so ticking the box
re-lays the open preview out immediately instead of rebuilding the document
and re-fetching every photo.

Breaking per day stays the default — this only adds the other option.
2026-07-29 21:32:39 +02:00
Maurice dc4ccb2304 chore(i18n): add the PDF page break label 2026-07-29 21:32:39 +02:00
jubnl a7cb4eb880 chore: point MCP.md to the wiki 2026-07-29 21:24:57 +02:00
jubnl 09f7cc4811 fix(maps): repair the quirks preserved by the DI fold 2026-07-29 20:27:30 +02:00
jubnl 74e1e444c5 docs: regenerate the legacy-services dependency graph after the maps fold 2026-07-29 20:21:10 +02:00
jubnl 4706112516 feat(server): adopt zod DTOs for the maps body contracts 2026-07-29 20:21:09 +02:00
jubnl 2e40c19249 chore: move maps backend to proper DI 2026-07-29 20:20:57 +02:00
Maurice 397f504d01 fix(planner): reach the day reorder popup with a finger too
The popup is a modal, so it portals out of the sidebar and misses the
opt-in its rows sit inside everywhere else. Its rows already carried a bare
draggable attribute, which on a tablet meant a long press selected the row
label and nothing moved.
2026-07-29 19:15:57 +02:00
Maurice edea67ff7e fix(planner): let tablets drag places into the day plan (#1616)
Dragging a place from the right pane to the left one did nothing on an
iPad or a Samsung tablet — the row was selected as text instead. Phones
never reach this layout, they get the mobile shell below md, so the coarse
pointer check in dragDisabled was only ever switching tablets off.

It was also doing that for a reason that had gone away. What swallowed the
scroll gesture in #1432 was the drag-drop-touch polyfill, and that has been
confined to hybrid laptops since. Width is the gate that still means
something: below lg the plan and the places are separate tabs, so there is
nothing to drag between.

So touch is no longer a reason to disable the drag, and the planner arms
the long-press bridge instead where the pointer is coarse. Rows get
-webkit-touch-callout and user-select off there too, otherwise the press
that starts the drag brings up the selection first. The isTouch prop had no
other use and is gone, along with a comment claiming arrow reorder buttons
take over on touch — there are none.
2026-07-29 19:15:57 +02:00
Maurice e7ccbcdf86 feat(planner): add a long-press touch drag bridge
A finger never starts an HTML5 drag on Android, and the answer used to be
drag-drop-touch: a document-wide polyfill that turned every touch over a
draggable element into a drag. That cost the places list its scrolling
(#1432) and the map its one-finger pan (#1440), so it now only loads on
hybrid laptops.

This is the narrow version. It only watches touches that begin on a
draggable row inside a container that opted in, and it waits out a long
press before claiming the gesture, so a swipe is still a swipe. Once armed
it replays the real drag sequence on the element under the finger, which
leaves every existing drop handler working as it is.

Nothing is wired up to it yet.
2026-07-29 19:15:57 +02:00
Maurice 98ad2450a2 feat(db): make the SQLite journal mode configurable (#1675)
WAL was hardcoded, and on network-backed storage — Azure App Service Linux,
SMB/NFS volumes on NAS and PaaS — SQLite documents it as unsafe, because the
coordination runs through the -shm file and mmap. Operators had no way out.

TREK_DB_JOURNAL_MODE and TREK_DB_SYNCHRONOUS now set both pragmas, defaulting
to today's behaviour. The mode lives in the file header, so all three
processes that open travel.db read the same configuration — the server,
reset-admin.js and migrate-encryption.ts, which used to hard-set WAL and would
have silently reverted a deliberate DELETE setup on the first key rotation.

An invalid value falls back and warns rather than refusing to start, and the
mode that actually took effect is read back and logged, so an operator can see
whether the setting landed.
2026-07-29 17:02:56 +02:00
Maurice e62dceb4bb fix(backup): snapshot the database instead of archiving it live (#1675)
The scheduled backup archived travel.db directly. The archiver reads its
entries during finalize(), so a WAL checkpoint writing pages back mid-stream
tore the copy — and the -wal that would make it recoverable isn't in the zip.
The manual path was moved to VACUUM INTO for exactly this reason; the
scheduler never followed. It calls the same createBackup() now, which also
gets it the encryption key and plugin data it was missing, so a scheduled
backup is finally restorable onto another install.

The pre-rotation copy in migrate-encryption.ts had the same problem: a plain
file copy of a live WAL database, and it is the operator's only rollback if a
key rotation goes wrong.

Auto-backups now exclude the re-derivable photo caches, matching manual ones.
2026-07-29 17:02:56 +02:00
Maurice 14bc41f2cf chore(i18n): drop the unused map template placeholder key
Left behind by the tile URL change (#1733): all four call sites use the
.select variant, the plain key had no consumer.
2026-07-29 15:14:21 +02:00
Maurice f35c3f31e2 fix(transit): prefer displayName for the line identifier (#1715)
Transitous exposes routeShortName, which many operators — German long
distance among them — do not fill. displayName carries the identifier
passengers actually see, with routeShortName as the fallback.
2026-07-29 15:14:21 +02:00
Maurice 4f99834965 test(admin): stop the plugin sheet test racing its own Escape
MSheet binds the Escape listener in an effect, so a key fired the moment the
sheet content appears can land before the listener exists. Under a loaded
full-suite run that made the test fail on its own timing. It retries until
the sheet is gone rather than pressing once.
2026-07-29 15:14:21 +02:00
Maurice 8b7e2ddaa5 fix(places): evaluate open/closed in the place's timezone (#1680)
The ring was checked against the viewer's clock, so a trip in Europe planned
from Singapore showed the wrong state. It evaluates in the timezone of the
place now, from Google's structured opening periods rather than the localised
description text — parsing display strings breaks as soon as the account
language is not English. Midnight-spanning hours, closed days and 24/7 are
covered.
2026-07-29 15:14:21 +02:00
Maurice 724b52aa92 fix(plugins): broadcast itinerary.unassign to open sessions (#1705)
assign broadcast, unassign did not, so the place stayed on the Plan tab until
a manual refresh. It emits the same event as the REST route now. createPlace,
updatePlace and deletePlace in the same file had the identical gap.
2026-07-29 15:14:21 +02:00
Maurice aaef3e3e76 fix(reservations): honour the 24h time setting (#1725)
The 12h/24h rules lived in three copies and two of them ignored the setting.
They are one function now. splitReservationDateTime also truncated the
meridiem instead of converting it, so 3:00 PM became 3:00 — three in the
morning. Values already stored that way normalise when a form renders the
field, without changing the payload or the stored format.
2026-07-29 15:14:21 +02:00
Maurice 9ab9665e99 fix(llm-parse): decode .eml as MIME before extracting text (#1724)
Attachments were treated as ready-made HTML, but a real mail is multipart
with base64 or quoted-printable transfer encoding. What reached the model was
the plaintext headers followed by a wall of base64, truncated before any
content — which is why a sender name from the Subject line still showed up
while the booking never did.

The html part is preferred, plain text is the fallback, transfer encoding and
charset are decoded and entities resolved before the text goes out. No new
dependency. If no text part is found the previous raw path still runs.
2026-07-29 15:14:21 +02:00
Maurice 50600b74cd fix(maps): stop the photo endpoint answering 404 for photo-less places (#1727)
Opening one trip fires a 404 per place without a photo, 30 to 60 of them from
one IP in seconds. CrowdSec and fail2ban read that as scanning; the reporter
had three users banned within five minutes. A place having no photo is not an
error and no longer answers as one.

OSM places were also sent to Google as node:/way:/relation: ids, which can
only return 400 INVALID_ARGUMENT — a billed round-trip and about 1.3s per
place. getPlacePhoto already guarded coords: this way; the OSM prefixes now
get the same treatment, as does getPlaceDetailsExpanded, which had no guard.

The negative cache separates a place having no photo from a failed provider
call instead of expiring both after five minutes and replaying the storm.
2026-07-29 15:14:21 +02:00
Maurice 9ecca404c5 fix(map): drop the retired OpenStreetMap tile subdomains (#1733)
The a/b/c shards stopped being necessary in 2022 and d has since lost its
DNS record. The map never asked for d — Leaflet defaults to abc — but the
offline prefetcher rotated over a to d, which is where the failing requests
came from, and it cached most tiles under a host the map never requests.

Presets and the prefetcher use the apex host now. A URL already saved in
user_settings is rewritten where it is read and written back, so existing
instances heal without a migration touching what someone typed themselves.

The CSP only allowed https://*.tile.openstreetmap.org, and a wildcard host
never matches the apex domain, so the prefetch fetch would have been blocked.
The service worker rule had the same gap and would have stopped filling the
tile cache.
2026-07-29 15:14:21 +02:00
Maurice f013c4964c chore(client): set every coverage threshold to 85
Treats the gate as a floor rather than a target — the suite runs well above it,
and a PR that legitimately trades a few points should not have to move the
numbers to land.
2026-07-29 12:56:17 +02:00
Maurice 72a82b3c44 fix(client): work through the findings from the coverage pass
214 items came out of writing the tests. Real defects are corrected, dead code
is removed rather than covered, and the tests that pinned the old behaviour
move with them.

Also drops two agent coverage scratch files that slipped into the coverage PR
and adds the ignore rule that keeps them out.
2026-07-29 12:35:00 +02:00
Maurice a4bf815d24 test(import): read the upload body instead of parsing it as FormData
These are the only two test files that call request.formData() inside an MSW
handler, and they hold exactly the three tests that failed on CI while passing
locally. The body axios' XHR adapter produces under jsdom is one Node 24's
multipart parser rejects, so the handler threw before reading a field and the
component reported a failed upload. Reading the raw payload asserts the same
thing without depending on the parser.
2026-07-29 11:55:31 +02:00
Maurice 1d9fe9a3df test(import): stop the upload fixtures lying about their size
The helpers built a one-byte File and then redefined size to 100, so the
multipart body and the length it announced disagreed. Node 24 rejects that
while 22 lets it through, which is why only CI saw it: every test that
uploaded such a file and expected success failed, the one expecting an error
passed for the wrong reason, and the KML case with a truthful File was fine.
2026-07-29 11:55:31 +02:00
Maurice be0164eb91 ci: run the client gates alongside the suite instead of in front of it
The client suite is ~11 minutes now. With typecheck and lint sitting in front
of it in the same job, a single lint finding threw away the whole test signal
for that run — twice today. Both jobs pay the install and the shared build,
which is cheap next to running the two in series.
2026-07-29 11:55:31 +02:00
Maurice 16922766e5 test: give async assertions a realistic budget on CI
waitFor and findBy* default to 1s. That holds on a dev machine but not on a
4-core runner executing 527 test files in parallel forks, where three import
dialog tests timed out waiting for their upload to settle.
2026-07-29 11:55:31 +02:00
Maurice 99816bff4c test(costs): escape the BOM in the CSV export assertion
A literal U+FEFF inside a regex trips no-irregular-whitespace, which is an
error rather than a warning, so the client lint job failed. The escape is
equivalent.
2026-07-29 11:55:31 +02:00
Maurice 02ad6c3d16 test: close the remaining gaps and raise the thresholds
Pushes lines from 95.2% to 98.1% and branches from 85.2% to 89.9%, mostly
in the release-notes modal, the reservation overlay, the journey entry sheet
and the trip dialogs.
2026-07-29 11:55:31 +02:00
Maurice b80bd3992b test: pick up the rest 2026-07-29 11:55:31 +02:00
Maurice 1e66bcc701 test(pages): cover the page hooks, stores and the api client 2026-07-29 11:55:31 +02:00
Maurice bbb0f93af2 test(components): cover map, collections, settings, journey and admin 2026-07-29 11:55:31 +02:00
Maurice a3b263017f test(planner): cover the day plan sidebar and the planner dialogs 2026-07-29 11:55:31 +02:00
Maurice 12558fe9c4 test(mobile): cover admin, settings and the remaining screens 2026-07-29 11:55:31 +02:00
Maurice c071901e55 test(4-0): cover the KEINE ANGST show 2026-07-29 11:55:31 +02:00
Maurice c884ddf527 test(mobile): cover the trip screens 2026-07-29 11:55:31 +02:00
jubnl 287dceb532 chore: wire TripsService into the plugin RPC host and retire the legacy tripService
Swap the factory's six tripService imports for the injected TripsService; delete services/tripService.ts and the collab/vacay bridges with it; prune the days/budget bridges to their surviving exports; update the migration docs and regenerate migration-graph.md.
2026-07-29 01:53:54 +02:00
jubnl 530c7cb005 chore: move trip backend to proper DI
Fold the legacy tripService SQL into TripsService; port the trips MCP registrar/resources/trip-summary prompt to trips.mcp.ts and the share-link tools to share.mcp.ts (predicate access for the trips:delete/trips:share/canReadTrips gates, deprecation notice via the attach ctx); adopt trips.dto.ts and clear the seven allow-list entries; delete todo/share bridges with their last consumers. Includes the trailing quirk fixes: transactional deleteTrip/deleteGuest, owner display_name in listMembers.
2026-07-29 01:53:40 +02:00
jubnl a3c2cfa4b2 fix(shared): tighten WS payload schemas after the MCP drift fixes
assignment:moved requires newDayId again; assignment:reordered and
day:deleted drop their drift-union variants now that every emitter
sends the canonical shape.
2026-07-29 00:34:58 +02:00
jubnl 23486f841f fix(server): align MCP assignment/day WS payloads with the REST shapes
move_assignment omitted newDayId (moved assignments vanished from
collaborator views), reorder_day_assignments sent assignmentIds where
the client reads orderedIds (reorders emptied the day remotely), and
delete_day sent { id } where the client reads dayId (days never
disappeared). Registry drift findings from the WS contract work.
2026-07-29 00:34:53 +02:00
jubnl a8a5f7e611 chore: update sonar props 2026-07-29 00:24:26 +02:00
jubnl a2011dc991 feat(client): handle WS events against the shared registry
remoteEventHandler's two switches become enumerable lookups keyed by
TrekWsTripEventName (bodies unchanged); WebSocketEvent.type derives
from the registry. wsEventPolicy.ts makes the client's non-reactions
explicit (HANDLED_OUTSIDE_TRIP_STORE + IGNORED_WS_EVENTS) and the
registry-parity test turns an unclassified server event into a red
test instead of silence. Zero behavior change.
2026-07-29 00:17:47 +02:00
jubnl f66f8d8bd9 feat(server): type RealtimeService against the event registry
broadcast/broadcastToUser gain registry-typed overloads (event name
must be a TREK_WS_EVENTS key, payload must match its schema) with the
plugin: namespace as the deliberate escape hatch; the 13 service
wrappers forward the generic signature. Compile-time only — the
runtime pass-through and all call sites are unchanged.
2026-07-29 00:16:08 +02:00
jubnl c0baaeb898 feat(shared): WS event contract registry
TREK_WS_EVENTS: 65 trip-scoped + 29 user-scoped events with Zod
payload schemas transcribed from the emitting call sites; derived
name unions and payload types; plugin: namespace reserved as the
escape hatch. 8 payload-drift shapes modeled as unions and flagged.
2026-07-29 00:15:56 +02:00
jubnl a2861d40f3 feat(server): injectable RealtimeService over the ws singleton
Nest services inject RealtimeService (call-time delegation to the
websocket module, DatabaseService pattern) instead of importing
broadcast/broadcastToUser globals. Zero behavior change; test edits
are constructor-arg additions plus RealtimeModule in e2e harness
imports only.
2026-07-29 00:07:51 +02:00
jubnl 80f3958569 fix(server): silence expected-error noise in test stderr 2026-07-28 22:31:46 +02:00
jubnl c7dc38a5b9 fix(server): close the notification-send teardown race in tests 2026-07-28 22:31:40 +02:00
jubnl 65fb00d63b feat(server): type MCP access groups against SCOPES + boot gate 2026-07-28 20:56:43 +02:00
jubnl 59ea420f1d feat(nest-mcp): host-typed access groups + entry validation hook 2026-07-28 20:56:29 +02:00
jubnl ee2ea1d065 refactor(server): extract addon enablement into the addons domain 2026-07-28 20:22:01 +02:00
jubnl cafff003a3 refactor(server): move instance-URL resolution to app-config 2026-07-28 20:21:09 +02:00
Maurice 292f1b181c Document track colours in the wiki
Map-Features had no GPX section at all, so the tracks paragraph is new
rather than an addition.
2026-07-28 07:25:36 +02:00
Maurice 2b8b55aa8f Translate the track colour strings 2026-07-28 07:25:36 +02:00
Maurice 502f604ca2 Reach the track colour from the mobile sheet
MPlaceSheet had no relation to route_geometry at all, so the section is
new. It saves through the single-field patch the sheet already uses for
the image, which keeps it clear of the edit form and its prefill.

The browse list gets the same colour stroke as the desktop sidebar. It
matters more here: the mobile map is full-bleed with no sidebar next to
it, so without the stroke a coloured line has nothing to tie it to.
2026-07-28 07:25:36 +02:00
Maurice 3c575a06f4 Add the track colour picker to the place inspector
New shared component rather than a ninth copy of the swatch grid that is
already inline in eight places. Selection is drawn with var(--accent) so
it follows whatever accent the user runs.

The custom-colour cell listens for the native change event instead of
React's onChange: for a colour input React maps onChange to input, which
fires on every drag inside the OS picker — dozens of writes, each one a
409 candidate against the base-version header. The auto cell previews the
colour you would get back, not the one currently set, which is a
different value as soon as anything is picked.

The row sits next to the track stats block, not inside it: that block
returns null on unparsable geometry and the colour control has no
business disappearing with it. The elevation chart and the distance pin
follow the track colour too, otherwise the panel and the map read as two
unrelated objects.

In the places list the track icon becomes a stroke in the colour the line
is drawn in. The map has no legend of its own, so that stroke is the only
thing tying a line back to a row. Undo after deleting a track now
restores its geometry and colour instead of bringing back a bare point.
2026-07-28 07:25:36 +02:00
Maurice fcabb4c645 Draw tracks in their own colour on both map engines
The MapLibre layer was already data-driven, so it only needed the feature
property filled. Leaflet needed more work: the GPX polyline passed its
style as bare color/weight props, and react-leaflet only calls setStyle
when the pathOptions reference changes — a recoloured track would have
kept its mount-time colour until the next reload. Every other vector
layer in here already uses pathOptions.

A track with a colour gets a white casing so it survives satellite
imagery and dark basemaps. The casings live in their own pane below the
lines: Leaflet stacks paths in insertion order, so drawn inline they end
up over the line of a track that was added earlier. They are always
mounted and hidden by opacity, otherwise giving a track a colour remounts
the path into the wrong position.

Tracks are clickable now — 3.5px is not a target, so the hit area is a
transparent 14px line on top. bubblingMouseEvents is off on it: paths
bubble to the map by default (markers do not), and the map's own click
handler would clear the selection the track just made. The GL side needs
the mirror image of that, a guard so a click on a cluster bubble over a
track still belongs to the cluster.
2026-07-28 07:25:36 +02:00
Maurice 1b5317a817 Give GPX tracks their own colour (#776)
Imported tracks all render in the same blue: the GPX/KML importers never
assign a category, so category_color is null for every one of them and
several walks in the same area are impossible to tell apart.

Adds a nullable route_color on places, kept out of the COALESCE group in
updatePlace so an explicit null can reset a track back to inheriting its
category colour. The hex is validated in the Nest controller rather than
in the shared contract: the update body is an open record on purpose, and
tightening it would mean removing PlacesController.update from the
ratchet-only body-contract allow-list.

The importers now hand each new track a colour of its own, picked from
the palette entries the trip is not already using. That happens in the
Nest service over the injected DatabaseService — the colour assignment is
new logic, and new logic does not belong in the legacy place service (see
server/src/nest/README.md). The field itself is only threaded through the
existing update/create statements, which the proportionality rule covers.

Also fixes duplicateTrip, whose explicit column list has been silently
dropping route_geometry all along, so a duplicated trip lost its tracks.
2026-07-28 07:25:36 +02:00
jubnl 351b5fb4ec fix(server): fix the quirks preserved by the budget DI migration 2026-07-28 00:04:50 +02:00
jubnl 40f3103c04 docs: tick budgetService in migrate.md and refresh the migration graph 2026-07-27 23:48:23 +02:00
jubnl cf1b9e4c06 feat(server): validate budget request bodies with shared Zod DTOs 2026-07-27 23:48:23 +02:00
jubnl 7f34dadf70 chore: move budget backend to proper DI 2026-07-27 23:48:05 +02:00
jubnl b0041f34ba fix(server): fix the quirks preserved by the exchange-rates DI migration
- the Frankfurter fetch now carries an AbortSignal timeout (10s) and a 1 MB
  response-size cap, and the body is boundary-validated through a type guard
  instead of an as-cast
- fetch failures are logged instead of silently swallowed (callers still get
  the graceful null)
- the inflight-coalescing cleanup moved into .finally so a rejected fetch
  can no longer leak the key
- the dead convertWithRates export (zero call sites) is dropped from the
  service and the bridge

Kept on purpose: the beyond-TTL stale-cache fallback, the >1-keys
empty-response heuristic and the || falsy-coercion defaults.
2026-07-27 23:03:59 +02:00
jubnl 166fe798a7 docs: tick the exchangeRateService fold in migrate.md and refresh the migration graph 2026-07-27 22:58:14 +02:00
jubnl a3e6eb52a4 chore: fold exchangeRateService into the nest budget domain 2026-07-27 22:58:09 +02:00
jubnl 132eb2035f fix(server): fix the quirks preserved by the permissions and auditLog DI migrations
Permissions: stored levels are validated at load (corrupt/out-of-range rows
fall back to the default consistently across all readers), the load-error
swallow is narrowed to missing-table (anything else logs), and an
all-skipped save no longer opens a transaction or flushes the cache.
The module-scoped cache stays by design (shared DI+bridge invalidation),
and the checkPermission 'admin' case stays — it is reachable (correct deny
for non-admins on admin-level actions), not dead code.

Audit: data/logs creation is lazy (no import-time disk side effect; the
LOG_LEVEL freeze remains — tests/setup.ts timing contract), the log helpers
gate on a real severity threshold (error < warn < info < debug; prod
default 'info' unchanged), file-IO failures leave a console.error trace
instead of vanishing in bare catches, and resolveUserEmail treats only
null/undefined as anonymous so a real id 0 resolves via the DB.
2026-07-27 22:34:41 +02:00
jubnl 2944cd32a1 docs: tick permissions and auditLog in migrate.md and refresh the migration graph 2026-07-27 22:09:08 +02:00
jubnl 6ff936c613 chore: move auditLog backend to proper DI 2026-07-27 22:09:08 +02:00
jubnl 1d56de3897 chore: move permissions backend to proper DI 2026-07-27 22:08:37 +02:00
jubnl 55cb2148d4 fix(server): fix the quirks preserved by the day DI migration 2026-07-27 20:58:03 +02:00
jubnl c276e3a79a docs: tick dayService in migrate.md and refresh the migration graph 2026-07-27 20:57:18 +02:00
jubnl 2d1eec76c3 chore: move day backend to proper DI 2026-07-27 20:57:01 +02:00
jubnl e62628bf93 feat(server): adopt zod DTOs for the day and accommodation body contracts 2026-07-27 20:56:45 +02:00
jubnl d952eaf4d4 fix(server): fix the quirks preserved by the reservations DI migration
Two verified defects carried through the parity fold:

- The create-path accommodation metadata sync gated on the raw
  accommodation_id instead of the resolved one, so a hotel whose
  accommodation was just auto-created never received its metadata
  check-in/out times or confirmation. Now keyed off resolvedAccommodationId
  (RESV-SVC-006 re-pins the fixed behavior).
- The multi-statement writes ran outside transactions: create (accommodation
  insert + reservation insert + endpoint save + metadata sync), update
  (accommodation upsert + update + endpoint replace + sync), remove (the
  3-delete cascade), setReservationTravelers (delete + inserts) and
  resyncReservationDays now run in db.transaction() — a mid-write failure no
  longer leaves partial state (RESV-FIX-001/002 pin the rollbacks).

Left as-is on purpose: the truthy updatePositions dayId check, the
empty-string COALESCE keeps on title/status/type, the TEXT accommodation_id
normalization, the optional day_plan_position wire tolerance and the
swallowed notifyBookingChange catches — all contract or intentional
behavior, not defects.
2026-07-27 19:52:00 +02:00
jubnl c7dde2af2e docs: tick reservationService in migrate.md and refresh the migration graph
- nest/README.md: reservations joins the DI-native list; recipe history gains
  the residue-fold paragraph; next up is dayService per the dependency-honest
  order.
- migrate.md: wave-4 entry ticked, with the migration-graph correction borne
  out — reservationService imported neither budgetService nor dayService.
- migration-graph.md: regenerated for the post-fold import graph (row/node
  removed, frontier and order updated, dayService is the next pick).
- plugins/DI-MIGRATION.md: the factory's last plain-function reservation
  import is drained.
2026-07-27 19:47:09 +02:00
jubnl 203d67c45f chore: move reservations backend to proper DI
Folds the 626-line services/reservationService.ts into the DI-native
ReservationsService — byte-identical SQL, statuses, bodies, error strings
and broadcasts; the legacy file is deleted.

- reservations.mcp.ts: the 5 legacy tools (create/update/delete_reservation,
  reorder_reservations, link_hotel_accommodation) + the trip-reservations
  resource move to the decorator registry (identical names, schemas,
  annotations, error strings, broadcasts); the canWrite/canRead registration
  gates become declarative access markers. Legacy registrar and resource
  entries removed.
- reservations.bridge.ts: 9 legacy-named exports + 3 type re-exports for the
  outside-container consumers (tripService, airtrail import/sync, the
  still-legacy transit + transports registrars, transitItineraryService's
  type-only import).
- notifyBookingChange folds to the legacy (tripId, actorId) signature; the
  controller passes user.id and the plugin-host factory drops its last
  plain-function reservation import for a private method over the injected
  service (constructor unchanged).
- TripsService and BookingImportService inject ReservationsService instead of
  importing legacy functions.
- Tests: reservations.service.test.ts rewritten as a real-SQL suite
  (RESV-SVC-001..030, RES-TRAV-001..005 moved verbatim,
  RESV-BRIDGE-001..009 pinning the bridge); both e2e suites converted to the
  DI-native pattern; path mocks repointed to the bridge or constructor stubs;
  tools-reservations kept and extended with the moved resource cases.

Quirks preserved on purpose (parity first — see the follow-up fix commit):
the raw-accommodation_id metadata-sync gate, the un-transactioned
multi-statement writes, the truthy updatePositions dayId check, the
empty-string COALESCE keeps, and the TEXT accommodation_id normalization.
2026-07-27 19:47:01 +02:00
jubnl c27fc99220 feat(server): adopt zod DTOs for the reservation body contracts
ReservationsController's 4 grandfathered @Body() params now validate through
the global ZodValidationPipe (createZodDto over the existing @trek/shared
schemas); their allow-list entries are removed (the boot gate rejects stale
entries). The bespoke 400 bodies (Title is required / positions must be an
array / user_ids must be an array) become the pipe's field envelope, and
non-numeric traveler ids now 400 instead of being silently dropped.

reservationPositionsRequestSchema is loosened to the real wire contract:
day_plan_position is optional (the legacy route never validated position
items and an absent value binds NULL) — pinned by RESV-006.

AccommodationsController stays grandfathered until the dayService migration.
2026-07-27 19:45:41 +02:00
jubnl fbc773386f fix(server): fix the quirks preserved by the vacay DI migration 2026-07-27 18:39:53 +02:00
jubnl e3547ec43b docs: tick vacayService in migrate.md and refresh the migration graph 2026-07-27 18:39:52 +02:00
jubnl 588457710c chore: move vacay backend to proper DI 2026-07-27 18:39:52 +02:00
jubnl 29e89a5774 feat(server): adopt zod DTOs for the vacay body contracts 2026-07-27 18:39:47 +02:00
jubnl a8d6663dc7 fix(client): send multiple_choice from the desktop poll form 2026-07-27 00:27:12 +02:00
jubnl 07b8f1234c fix(server): fix the quirks preserved by the collab DI migration 2026-07-27 00:27:12 +02:00
jubnl 20882f47b7 docs: tick collabService in migrate.md and refresh the migration graph 2026-07-27 00:27:12 +02:00
jubnl 49197c5370 feat(server): adopt zod DTOs for the collab body contracts 2026-07-27 00:27:11 +02:00
jubnl c08c595b7e chore: move collab backend to proper DI 2026-07-27 00:27:11 +02:00
jubnl 470e4dc92c feat(shared): make collab note optional fields nullable in the request contracts 2026-07-27 00:27:06 +02:00
jubnl f69731b72b docs(server): chronicle the fileService DI migration 2026-07-26 23:19:32 +02:00
jubnl f3e5be78c9 chore: move files backend to proper DI
Folds the legacy services/fileService.ts into the DI-native FilesService
(injected DatabaseService, byte-identical SQL). Load-time constants move to
files.constants.ts; files.bridge.ts survives with a single export for the
request-time getAllowedExtensions read the module-scope multer configs need
outside DI. TripsService and PluginHostDepsFactory inject FilesService.

Also rides the nestjs-zod ratchet (FileUploadDto/FileUpdateDto/FileLinkDto
over the @trek/shared contracts, FilesController entries dropped from the
body-contract allow-list) and fixes the verified legacy quirks: file-link
insert failures now surface instead of being swallowed, updateFile coerces
an empty-string description to NULL like createFile, and formatFile's
misleading optional trip_id annotation is gone.
2026-07-26 23:19:26 +02:00
jubnl dd3c03ba0f feat(shared): add fileUploadRequestSchema for the files upload body 2026-07-26 23:19:04 +02:00
jubnl 4a7d9cd0be refactor(plugins): route injectable-class SQL through the injected DatabaseService
PluginRuntimeService's operatorEgressHosts/pruneErrorLog and PluginsService's
egressHostCount were module-level helpers on the raw db proxy; they fold into
their classes as methods over the injected service. (PluginHostDepsFactory's
equivalent conversion rode the settings commit — same files.) No injectable
class under src/nest imports the db global anymore; the remaining importers
are the documented module-level seams (bridges, platform shell,
plugin-host-state, signature-status, the bare plugin user-setting readers).
2026-07-26 22:24:24 +02:00
jubnl e0530680cb feat(server): adopt zod DTOs for the settings body contracts
SettingUpsertDto/SettingsBulkDto wrap the existing @trek/shared schemas; both
SettingsController entries drop off the body-contract allow-list (boot
ratchet). Per the todo precedent the dead bespoke 400 guards go: missing/empty
key and a non-record settings body now answer with the pipe envelope
({ error: 'key: …' } / { error: 'settings: …' }) instead of 'Key is required' /
'Settings object is required', and arrays for settings are rejected. The local
masked-value literal now imports MASKED_SETTING_VALUE from @trek/shared.
The settings e2e registers ZodValidationPipe and pins the DTO 400s plus the
bulk masked-sentinel skip.
2026-07-26 22:24:14 +02:00
jubnl cece6509b9 chore: move settings backend to proper DI
Folds the legacy services/settingsService SQL into the Nest SettingsService
(byte-identical statements; BEGIN/COMMIT blocks become db.transaction()).
AdminService and ShareService inject it; llm-config.resolver becomes the
injectable LlmConfigResolver (SettingsService + DatabaseService), injected by
LlmParseService and PluginHostDepsFactory. No MCP surface, no bridge.

Whole-file commit, so this also carries the post-migration quirk fixes that
live in the same files: null serializes as '' (a stored "null" leaked out of
getDecryptedUserSetting as the literal string), bulk upserts skip the masked
sentinel (mirroring the single-upsert no-op) and return the written count.
Unit suite moved to tests/unit/nest with SET-SVC-001..026 preserved,
027..030 pin the fixes; settings/admin e2e converted to DI-native.
2026-07-26 22:23:57 +02:00
jubnl c9a9df5310 fix(server): harden share links — transactional writes, TTL renewal on update, scoped shared queries 2026-07-26 21:25:47 +02:00
jubnl d572b1ec8d feat(server): adopt zod DTOs for the share-link body contract 2026-07-26 21:25:34 +02:00
jubnl 72c3edda1a chore: move share backend to proper DI 2026-07-26 21:24:57 +02:00
jubnl efd0e7fa98 fix(server): import MCP session revocation from sessionManager, not the mcp barrel 2026-07-26 20:34:21 +02:00
jubnl 41b51849b7 feat(server): adopt zod DTOs for the assignment body contracts 2026-07-26 20:34:06 +02:00
jubnl 164f5152be chore: move assignments backend to proper DI and harden its writes 2026-07-26 20:33:31 +02:00
jubnl 9650ad4597 feat(server): adopt zod DTOs for the trip-invite body contracts
New tripInviteLinkCreateRequestSchema in @trek/shared (single source of
truth), a createZodDto wrapper on the controller's raw @Body(), and the
TripInviteLinkController.create entry dropped from the body-contract
allow-list (the boot ratchet enforces it).

expires_in_days accepts number | digits-only string | null | absent — the
string branch is digits-only so garbage like "7abc" is a 400 instead of
silently parseInt-ing to 7; the empty string (blank form input, meaning no
expiry) stays accepted. The e2e registers ZodValidationPipe and pins both
the bounded-expiry and 400 paths.
2026-07-26 18:21:18 +02:00
jubnl cb0cd3300d chore: move trip-invite backend to proper DI
Fold the legacy tripInviteService SQL into the Nest TripInviteService over
the injected DatabaseService (byte-identical statements; the JS-side expiry
check and non-positive-means-no-expiry coercion preserved) and delete the
legacy module. No bridge, no MCP port and no plugin-host change needed —
nothing outside the container consumed this domain.

Also wraps the rotation's probe + write + re-select in db.transaction()
per the repo rule that multi-statement writes are never bare.

Unit suite moved to tests/unit/nest preserving TRIP-INVITE-001..006 and
adding 007 (expiry pinning); the e2e converted to the DI-native pattern
(real SQL against temp trips/trip_invite_tokens tables).
2026-07-26 18:21:04 +02:00
jubnl 3762d3306c feat(server): adopt zod DTOs for the day-note body contracts 2026-07-26 17:44:25 +02:00
jubnl 4e49e9c21e chore: move day-notes backend to proper DI 2026-07-26 17:44:12 +02:00
jubnl 7242812b27 fix(trips): scope the offline bundle's packing items to the viewer (#858) 2026-07-26 16:57:02 +02:00
jubnl 934848e114 fix(packing): correct legacy quirks kept during the DI migration 2026-07-26 16:56:18 +02:00
jubnl 5da2abf4f9 feat(server): adopt zod DTOs for the packing body contracts 2026-07-26 16:55:53 +02:00
jubnl f1960dc364 chore: move packing backend to proper DI 2026-07-26 16:55:20 +02:00
jubnl 67b3005d36 docs(plugins): record Option A as implemented, correct bridge/next-up drift 2026-07-26 15:29:00 +02:00
jubnl 811230fa5d refactor(plugins): wire PluginHostDepsFactory through DI, drop tags/categories bridges 2026-07-26 15:28:55 +02:00
jubnl 167db04d69 refactor(plugins): split create-rpc-host into an injectable factory + host-state module 2026-07-26 15:28:39 +02:00
jubnl 6c583c2f27 feat(server): adopt nestjs-zod with a fail-closed body-contract boot gate
- One global ZodValidationPipe (APP_PIPE, createZodValidationPipe) validates
  any @Body() typed with a createZodDto class; the custom exception factory
  keeps TREK's { error: 'field: message; ...' } envelope byte-identical.
- The 7 piped controllers (collections, places, vacay, todo, health,
  airtrail x2) now type bodies with colocated <domain>.dto.ts wrappers —
  the Zod schemas in @trek/shared stay the single source of truth.
- OpenAPI: the hand-rolled api-zod.ts enricher is replaced by nestjs-zod's
  cleanupOpenApiDoc; request bodies surface as $refs to named DTO schemas.
- validateBodyContracts() runs in buildApp() after init and refuses to boot
  when any POST/PUT/PATCH @Body() (including @Body('field') sub-reads) lacks
  a ZodDto metatype. The 175 pre-existing legacy handlers are grandfathered
  in body-contract-allow-list.ts; the list is ratchet-only — the gate also
  throws on stale entries, so migrating a domain forces their removal.
2026-07-26 14:55:13 +02:00
jubnl 8e6b3a424e style(shared): apply eslint --fix line wrapping to locale and schema files 2026-07-26 14:54:59 +02:00
jubnl 2fa075a076 fix(shared): align todo contracts with the wire format the client sends
The optional metadata fields (category, due_date, description,
assigned_user_id — plus priority on update) now accept explicit null: the
client clears fields by sending null, which the schemas rejected. checked
accepts the legacy 0/1 numeric form alongside boolean (the integration net
pins it). Drops the client-side casts that papered over the too-narrow
types.
2026-07-26 14:54:53 +02:00
jubnl ad7d349c85 fix(todo): wrap category-assignee replacement in a transaction
updateCategoryAssignees ran its DELETE + INSERT OR IGNORE loop unwrapped, so
a mid-loop failure could leave a category half-cleared. Also widens the item
params to the nullable shapes the wire contract actually sends (prep for the
todo body validation).
2026-07-26 14:54:38 +02:00
jubnl 79946cc711 chore: move todo backend to proper DI 2026-07-26 14:09:54 +02:00
Maurice b5ebffa115 fix(dashboard): keep a trip the hero fell back to in the grid
The hero picks the trip that is running, else the next one coming up, else
just trips[0] — and that pick was then removed from the grid below. The first
two cases are deliberate: the trip is already on screen, showing it again
right underneath would be noise. The trips[0] fallback is a different thing.
It fires precisely when nothing qualifies, so it borrows a trip that belongs
in the grid, and removing it there leaves the user with a hero and an empty
list. Someone whose trips are all finished sees "No trips yet" under a card
of the trip they just came back from.

Split the two: only a trip that earned the hero is taken out of the grid.
Undated trips were caught by this too, and worse — getTripStatus returns
null rather than 'past' for them, so a lone dateless trip appeared in no
tab at all.

The empty state was the second half of the report. It equated "planned is
empty" with "you have no trips" and told people to create their first one.
Gate it on there being no trip at all, which is the condition the mobile
dashboard already uses.

Mobile shares useDashboard, so it is fixed along with the desktop.

Closes #1706
2026-07-26 12:45:54 +02:00
jubnl 2ce40724d7 chore: move category backend to proper DI 2026-07-26 12:36:30 +02:00
Maurice c8369bd14b fix(plugin): clear brokered session state on logout
sessionStorage outlives a logout inside the same tab, so the keys stay readable
after the next user signs in. They are namespaced by user id, which stops the new
user's plugins from reading them, but the data is still sitting in the tab — the
same reason logout already drops the appearance snapshot, the SW caches and the
user-scoped offline DB. Purge them there too.

Three things around it, from reading the handlers next to the new ones:

- The inactive-plugin purge ran before the store was updated, so a sessionStorage
  failure cost us the plugin list we had just fetched. Commit first, purge after.
- The session handlers answered without checking requestId, unlike confirm and
  geolocation — an answer the frame can never match is worth skipping.
- The mock host threw plain Errors while the real bridge rejects with .code set,
  and checked the key before the trip scope where the host does the reverse, so a
  plugin's error handling could pass its tests and still break in the app.
2026-07-26 11:48:15 +02:00
geracobo 2068133de2 feat(plugins): add quota-limited session storage API 2026-07-26 11:48:15 +02:00
geracobo cc8e4d480e fix(plugins): corrected non-sequential test numbers for PluginFrame 2026-07-26 11:48:15 +02:00
geracobo 9d8d0b1653 feat(plugins): add session storage api for plugin bridge 2026-07-26 11:48:15 +02:00
Maurice 0fc04909a8 docs(plugin): spell out what blurBookingCodes does, and preview it
The context table listed the new field without saying what a plugin is meant to
do with it. It is a display hint — booking codes still arrive in full over
trek:invoke — and reading it as redaction is the one mistake worth preventing.

The dev preview hardcoded it to false, so the blurred path was the only host flag
an author could not try out; it gets a toggle like reduce motion and the rest.
2026-07-26 11:48:06 +02:00
geracobo 16793761be fix(plugin): include blur booking codes on the plugin context 2026-07-26 11:48:06 +02:00
Maurice c4f4fdd794 fix(booking-import): cover the AI retry and keep its source files
The widget's new healthApi.features() call left BackgroundTasksWidget.test.tsx
red — the api/client mock never exported healthApi, so both existing tests threw
on mount. The mock now carries it, pending by default like the poll backstop, and
the retry itself finally has tests.

Writing them turned up two gaps. The retry never called saveImportFiles, so the
files only lived in memory: reloading during the retry parse left the review with
no source document to attach to the bookings it creates, unlike every import
started from the modal. And the button stayed live while the request was in
flight, so a second click started a second force-ai job on the same files.
2026-07-26 11:47:59 +02:00
Xre0uS 6854436c24 feat(booking-import): retry a failed import with AI parsing 2026-07-26 11:47:59 +02:00
Maurice eae2ade740 fix(booking-import): only resolve leg days from local_date on a prefill
Resolving every waypoint day from endpoint.local_date also changed the edit path,
where it inverts the precedence the form uses everywhere else: local_date is
denormalised and the server only restamps it in the day_id branch of insertDay,
and only when reservation_time is set. Dragging a booking to another day rewrites
day_id/end_day_id and leaves the endpoints alone, so local_date points at the old
day — and reopening the form and pressing Update silently moved the booking back.

Gate the lookup on a prefill, where there is no saved day to lose, and the edit
path behaves exactly as it did before. Same change in the mobile sheet, which
shares the seeding code and would otherwise still show empty day selectors on an
imported booking.
2026-07-26 11:47:52 +02:00
Xre0uS 68ebc700cb fix(booking-import): pre-fill leg dates from endpoint local_date 2026-07-26 11:47:52 +02:00
jubnl 8f3ee53841 feat(mcp): Create nest wrapper for MCP for proper DI (#1708) 2026-07-26 11:42:47 +02:00
Maurice 828c1db489 fix(offline): start the tile prefetch once the app is idle
syncAll runs straight off the back of a successful login, while the app
is still mounting its first screen. Kicking off a bulk background
download at that exact moment is the worst possible timing even with the
prefetch throttled, so hold the tiles back until requestIdleCallback
says the browser has nothing better to do.

Files keep their old behaviour — they are a handful of requests, not
thousands.
2026-07-26 10:54:22 +02:00
Maurice 3c67afeade fix(offline): throttle the map tile prefetch
The prefetch dispatched every tile of a trip's bounding box in one
synchronous loop — up to MAX_TILES per trip. A trip spanning a few
countries enumerates thousands of tiles at zoom 10 alone, so logging in
fired ~8k no-cors requests back to back and kept them coming for half a
minute. All of them go through the Workbox fetch handler, which then has
to write a cache entry plus an expiration record per tile, so everything
the app itself requested queued up behind the burst and the UI just sat
there.

Run the queue through a small worker pool instead, and let it stop early
when the user goes offline or logs out.

Two things fall out of that:

- Tiles already in Cache Storage are now skipped before the request is
  made, so a resumed or repeated prefetch over a warm cache is nearly
  free.
- {s} has to be a pure function of the tile coordinates for that lookup
  to hit. The rotating counter gave the same tile a different host on
  every run, which also meant caching it up to four times.
2026-07-26 10:54:22 +02:00
Maurice a3e2ff9fa5 fix(mobile): offer the currency on the dashboard trip sheet
The mobile create/edit sheet never showed the currency, so every trip
started from the dashboard silently took the server default of EUR and
could only be corrected from the trip planner, which reuses TripFormModal
and does have the field.

Same control as the desktop form — searchable select over currenciesWith(),
gated on trip_edit like the rest of the sheet.
2026-07-25 22:04:03 +02:00
jubnl 53e884ad55 refactor(nest): migrate tagService into TagsService DI (pilot)
Move the tag SQL from src/services/tagService.ts into TagsService over an
injected DatabaseService (identical statements, #10b981 default, COALESCE
semantics). Non-Nest consumers (MCP tools, plugin RPC host) go through the
new tags.bridge.ts, which exports the legacy function names 1:1 from a
module-level instance over the shared db Proxy — the settled pattern for
the remaining legacy-service migrations (recipe documented in
src/nest/README.md). Tests are DI-native: direct service construction in
unit (with bridge delegation cases for the coverage gate) and
DatabaseModule + real SQL in e2e.
2026-07-25 18:36:17 +02:00
jubnl dd2c2d6668 refactor(nest): inject DatabaseService in db-using controllers 2026-07-25 18:15:15 +02:00
jubnl fbe200424d refactor(nest): inject DatabaseService in db-using services 2026-07-25 18:15:04 +02:00
jubnl def6b84938 refactor(nest): inject DatabaseService in access-check services and controllers 2026-07-25 18:14:53 +02:00
jubnl 718022c7ba feat(db): provide better-sqlite3 connection via DI token and complete DatabaseService surface 2026-07-25 18:14:37 +02:00
jubnl 7059b686ac Merge remote-tracking branch 'origin/dev' into dev 2026-07-25 17:02:55 +02:00
jubnl 45c1568e7c chore: correct user setting currency i18n 2026-07-25 17:02:32 +02:00
Maurice c7a67ed58a fix(build): align the musl sharp pins with the sharp version
The bump to sharp 0.35.0 left the @img/sharp-linuxmusl-* pins on 0.35.1,
so npm nested the 0.35.0 binary under node_modules/sharp while hoisting
libvips to the root. The nested .node then looks for libvips-cpp.so.8.18.3
next to itself, finds nothing, and the Alpine image build dies in the
client prebuild:

  Could not load the "sharp" module using the linuxmusl-x64 runtime
  ERR_DLOPEN_FAILED: Error loading shared library libvips-cpp.so.8.18.3

Neither docker workflow runs on push, so this only surfaces when a release
is cut. Pin both binaries to 0.35.0 so they hoist next to libvips again.
2026-07-25 16:46:29 +02:00
jubnl 8a46c56a96 test(client): dismiss the CTA-style support modal in e2e setup 2026-07-25 16:30:33 +02:00
jubnl e162da6dbe fix(server): resolve zh-TW default language, close lint gap on bracket env access 2026-07-25 16:19:15 +02:00
jubnl f539c2aab7 Merge remote-tracking branch 'origin/dev' into dev 2026-07-25 16:06:29 +02:00
jubnl 0f3a6f80b7 chore(server): forbid direct process.env access outside app-config 2026-07-25 16:05:26 +02:00
jubnl 3b31f4256b refactor(server): move language/session parsing into app-config, config.ts becomes keys module 2026-07-25 16:01:42 +02:00
jubnl fb9abdb76c refactor(server): non-DI islands read env via app-config 2026-07-25 15:58:02 +02:00
jubnl 7f795a2199 refactor(server): legacy services read env via app-config 2026-07-25 15:37:37 +02:00
jubnl d13758221e refactor(server): remaining nest domains read env via app-config 2026-07-25 15:29:13 +02:00
jubnl 9e87e09d03 refactor(server): auth, oidc and admin nest modules read env via app-config 2026-07-25 15:21:17 +02:00
jubnl a34ab288f5 refactor(server): bootstrap and middleware read config via app-config 2026-07-25 15:17:33 +02:00
jubnl e17f165e44 feat(server): add nest AppConfigModule bound to @nestjs/config 2026-07-25 15:12:38 +02:00
jubnl 48ba9092c3 feat(server): add app-config core with fail-fast env validation 2026-07-25 15:03:27 +02:00
Maurice 39d4dc2675 feat(vacay): thin the mobile person card down to the number you act on
Used, carried over, comp days and remaining all fit a desktop sidebar; on a
phone they collapsed into four scraps of 9px text fighting for one row. Only
the balance is left, as a pill tinted by how much of it there is — the rest is
still a tap away in the entitlement view.

The toolbar chip says "Company" rather than "Company Holiday": it sits beside
two other buttons and the building icon already carries the meaning. The
allowance stepper reads "Days" instead of "days / year", reusing the label the
desktop entitlement tile has always used — the year is on the pill above it.
2026-07-25 14:49:38 +02:00
Maurice bccf4284c4 i18n(login): drop the keys the feature tiles used
login.features.* and login.selfHosted across all 23 locales — nothing renders
them any more.
2026-07-25 14:49:38 +02:00
Maurice 8b6a80fce0 feat(login): rebuild the branding panel and the sign-in moment around it
The panel dropped the eight feature tiles and the self-hosted line — they read
as a spec sheet on a screen whose job is to get you in. What is left is the
mark, the tagline and one line of copy, centred over the map, each with a soft
dark halo so the dots cannot eat the type. The tagline and subtitle never wrap:
they scale with the panel instead, since it is a fixed share of the viewport.

The takeoff after sign-in used to tell a different story than the screen it
came from — cloud ellipses, a lone plane, a contrail. It now picks the panel up
and escalates it: the same map, every route departing at once, the logo landing
as the network completes, then a soft hand-off into the app.
2026-07-25 14:49:38 +02:00
Maurice 45ce365920 feat(login): a living world map behind the sign-in panel
Replaces the drifting plane silhouettes with the thing TREK actually does:
routes lighting up between cities across a dot map of the world. Arcs leave a
city, a light travels the curve, the destination lands, and the arc retires to
make room for the next.

The map is drawn once into an offscreen layer and blitted per frame, so only
the handful of live arcs cost anything — no shadowBlur in the frame path. The
same component covers the sign-in moment via `variant="takeoff"`, where the
network fires as one cascade and stays lit instead of cycling.

Reduced motion gets the map as a still frame.
2026-07-25 14:49:38 +02:00
Maurice 0d9032224d feat(login): bake TREK's coastlines into a dot grid for the sign-in screen
The login screen is unauthenticated, so it cannot call the Atlas endpoint the
rest of the app uses for country geometry. This is that same admin-0 bundle,
sampled along the coastlines onto a 300x150 grid and delta-encoded — 15 KB
instead of a 28 MB fetch nobody is allowed to make yet.
2026-07-25 14:49:38 +02:00
dependabot[bot] b955998905 chore(deps-dev): bump sharp from 0.33.5 to 0.35.0 (#1690)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump sharp from 0.33.5 to 0.35.0

Bumps [sharp](https://github.com/lovell/sharp) from 0.33.5 to 0.35.0.
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.33.5...v0.35.0)

---
updated-dependencies:
- dependency-name: sharp
  dependency-version: 0.35.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 13:14:41 +02:00
dependabot[bot] 50c0864bce chore(deps): bump hono from 4.12.26 to 4.12.32 (#1692)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Revert "chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)" (#1694)

This reverts commit 94736112cf.

* chore(deps): bump hono from 4.12.26 to 4.12.32

Bumps [hono](https://github.com/honojs/hono) from 4.12.26 to 4.12.32.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.26...v4.12.32)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.32
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: jubnl <66769052+jubnl@users.noreply.github.com>
2026-07-25 13:12:58 +02:00
dependabot[bot] 60f888dc71 chore(deps): bump body-parser from 1.20.5 to 1.20.6 (#1693)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump body-parser from 1.20.5 to 1.20.6

Bumps [body-parser](https://github.com/expressjs/body-parser) from 1.20.5 to 1.20.6.
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/1.20.5...1.20.6)

---
updated-dependencies:
- dependency-name: body-parser
  dependency-version: 1.20.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 13:11:09 +02:00
dependabot[bot] 3b98721a64 chore(deps): bump shell-quote and concurrently (#1691)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump shell-quote and concurrently

Bumps [shell-quote](https://github.com/ljharb/shell-quote) to 1.9.0 and updates ancestor dependency [concurrently](https://github.com/open-cli-tools/concurrently). These dependencies need to be updated together.


Updates `shell-quote` from 1.8.4 to 1.9.0
- [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/shell-quote/compare/v1.8.4...v1.9.0)

Updates `concurrently` from 10.0.3 to 10.0.4
- [Release notes](https://github.com/open-cli-tools/concurrently/releases)
- [Commits](https://github.com/open-cli-tools/concurrently/compare/v10.0.3...v10.0.4)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-version: 1.9.0
  dependency-type: indirect
- dependency-name: concurrently
  dependency-version: 10.0.4
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 13:09:45 +02:00
dependabot[bot] 8bf8da18f6 chore(deps-dev): bump postcss from 8.5.16 to 8.5.23 in /plugin-sdk (#1688)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Revert "chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)" (#1694)

This reverts commit 94736112cf.

* chore(deps-dev): bump postcss from 8.5.16 to 8.5.23 in /plugin-sdk

Bumps [postcss](https://github.com/postcss/postcss) from 8.5.16 to 8.5.23.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.16...8.5.23)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: jubnl <66769052+jubnl@users.noreply.github.com>
2026-07-25 13:08:16 +02:00
dependabot[bot] 65913975d7 chore(deps): bump fast-uri from 3.1.2 to 3.1.4 (#1689)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump fast-uri from 3.1.2 to 3.1.4

Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.2 to 3.1.4.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.2...v3.1.4)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 13:05:51 +02:00
jubnl f1489534bb chore: bump dompurify to 3.4.12 2026-07-25 13:02:27 +02:00
jubnl e60427f813 Revert "chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)" (#1694)
This reverts commit 94736112cf.
2026-07-25 13:00:55 +02:00
dependabot[bot] 94736112cf chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 12:58:59 +02:00
Maurice fbd2d17fa8 docs(vacay): document the leave year and the comp/half modifiers
The page still described Vacay as strictly January to December and never
mentioned half days at all, so both new features would have shipped
undocumented.
2026-07-25 12:02:16 +02:00
Maurice 3c28c7eaeb test(vacay): cover the leave-year window and the comp exclusion
Windows for all three year types, the fallbacks (anniversary without a hire
date, a day the month cannot have), usage counted across the calendar-year
boundary, carry-over chained over periods rather than years, deleteYear over a
shifted period, and the reads with and without a viewer.

For comp: excluded from usage, a half comp day still costing nothing,
converting a vacation day back, and comp_used reported on its own.
2026-07-25 12:02:16 +02:00
Maurice 59128566d8 i18n(vacay): translate the leave-year and comp keys 2026-07-25 12:02:16 +02:00
Maurice b37996ec39 feat(vacay): make the toolbar show the marker it places (#1074)
The half-day button carried a ½ glyph and comp/flex a clock, neither of which
appears anywhere on the calendar. Both now show what a click actually puts on
the day: the orange dot for a half day, a hatched disc in the selected person's
colour for comp.

The digit on an all-comp cell also needed help. The hatch lets the surface
through between its stripes, so a white number sank into it — a shadow carries
the contrast now and every logged day keeps the same digit colour. Mixed days
still have a solid segment under the number and get none.
2026-07-25 12:02:16 +02:00
Maurice f1d520bbc2 feat(vacay): show comp days in the stats and fold the card away (#1074)
The server has returned comp_used since the leave type landed, but nothing
showed it — comp days simply vanished from the sidebar. They now sit beside
the tiles rather than inside the used/left arithmetic, which is the point of
them.

Carry-over is labelled from the window the server counted that row over, not
from the viewer's own setting, so a fused plan with mixed leave years does not
tell one member their days came "from 2025" when they came from a period that
started in July.

The card also folds now. It is the longest thing in the sidebar once several
people are fused in, and the state belongs to the person looking at it, so it
lives in localStorage rather than on the plan.
2026-07-25 12:02:16 +02:00
Maurice d984e09473 feat(vacay): pick a leave year and roll the grid over with it (#737)
Calendar, fiscal (month + day) or from the hire date, per user — it sits with
the plan settings but belongs to the person, so fused members each keep their
own and the grid follows whoever is looking at it.

Both grids render twelve months from the window start with year rollover
instead of a fixed Jan–Dec, and the mobile month navigation moves through slots
of that window, so stepping past December lands in January of the same period
rather than jumping back to the start of the calendar year. The month effect
keys on the window's shape rather than the settings object, which the store
replaces with an equal copy on every refresh — otherwise any sync would throw
away the month the user had open.

Known edge for a start day past the 1st: the cards are month-aligned, so the
first days of the start month are drawn but count toward the previous period.
Counting stays day-exact.
2026-07-25 12:02:16 +02:00
Maurice e1c5daeccb feat(vacay): mirror the leave-year window in the client (#737)
The server owns the arithmetic entitlements are computed with, but the grid has
to render the same twelve months and the overlays have to filter against the
same range, so the window resolves on both sides. yearWindow.ts is that mirror,
including the Feb-29 clamp and the anniversary-without-a-hire-date fallback.

The store loads the settings before the year list picks a default — with a
shifted year the current period is not necessarily the current calendar year.
Holidays are fetched per calendar year the window touches and clipped back to
what the grid draws; a school break straddling New Year comes back from both
fetches, so markers are de-duplicated per day. The label is part of that
identity, otherwise two calendars left on the same colour collapse into one.
2026-07-25 12:02:16 +02:00
Maurice 9d93ed95bf feat(vacay): read and write the leave year over its own window (#737)
Adds GET/PUT /addons/vacay/year-settings and makes the reads window-aware, so
the backend from the previous commit is actually reachable.

Entries and shared calendars now load over the caller's window instead of a
date prefix — a shifted year spans two calendar years and the prefix silently
dropped the second half. Reads are month-aligned (the grid draws month cards),
while entitlement stays day-exact; the two coincide for every window starting
on the 1st, which is all of calendar. deleteYear clears entries per author over
that author's period, and company holidays only over the range every member
shares, so one member deleting a period cannot reach into another's live one.
getStats reports the window it counted, and getOwnPlan seeds the period today
falls into rather than the calendar year.

A start day the month cannot have (Feb 29 from a hire date, a stored Feb 31)
is clamped to one every year has — otherwise the boundary string matches no
date and the whole window slides. Anniversary before a hire date is entered
resolves to the calendar year instead of borrowing a month a previous fiscal
setting left behind.

MCP reads pass their user through; without one the range stays the plain
calendar year, so plan-scoped callers are unchanged.
2026-07-25 12:02:16 +02:00
Maurice 2672a7a741 test(vacay): catch the suites up with the comp/flex column
The kind column changed four things the tests still asserted the old way:
toggleEntry gained a parameter, the toolbar gained the comp modifier, shared
calendar rows carry kind, and a multi-person cell is now segment overlays
instead of one split gradient. splitBackground went with it — nothing but the
test still referenced it.

The toolbar tests picked the half-day button by index, which the new button
shifted; they select by name now so the next modifier does not break them.
2026-07-25 12:02:16 +02:00
Maurice c475e5b04c feat(vacay): comp/flex leave type (#1074) + configurable-year backend (#737)
Comp/Flex days (#1074) — complete. A second leave type that does not deduct from the vacation entitlement (flextime / overtime offset). New kind column on vacay_entries, excluded from usedDays via a CASE sum; toggleEntry carries kind through service/nest/store with change-detection; desktop and mobile get a comp toolbar toggle next to the half-day one (orthogonal, so a half comp day is free); comp segments render as a diagonal hatch of the person colour, per-segment for split days (Version B). comp_used is surfaced separately in getStats. Translated into all 23 locales.

Configurable vacation year (#737) — backend only. Per-user vacay_user_settings (calendar / fiscal / anniversary), resolveYearWindow, and usedDays / compUsedDays / getStats / carry-over now computed over the leave-year [start,end) window. 'calendar' resolves byte-identically to the old date-prefix behaviour, so nothing changes for existing users and the feature is inert until the settings UI lands. Frontend (settings selector + calendar window rollover), the settings endpoint, window-aware getEntries and holiday loading are still to do — see the PR description.
2026-07-25 12:02:16 +02:00
Konstantinos Thermos c61203e607 fix(reservations): restrict all booking dates to the trip span 2026-07-24 19:16:55 +02:00
Maurice 8769ed084e refactor(reservations): move desktop traveler assignment into the edit/create modals
The always-on card picker is gone — travelers are assigned in the booking and transport modals now, matching mobile. The card shows assigned travelers read-only as pills in the grey field box, TravelerChips is replaced by a shared TravelerPicker, and the toolbar filter gets gradient avatars with an accent ring and check badge.
2026-07-24 16:50:41 +02:00
Maurice 2a5c54c025 feat(reservations): traveler filter on the mobile bookings and transports tabs 2026-07-24 16:50:41 +02:00
Maurice bc06125d6e test(reservations): server + client coverage for traveler assignment 2026-07-24 16:50:41 +02:00
Maurice e89dae5528 feat(reservations): traveler filter + transit-card badge (desktop) and full mobile traveler UI (cards + sheet pickers) 2026-07-24 16:50:41 +02:00
Maurice 8d7753c2e1 i18n(reservations): translate the traveler keys into all locales 2026-07-24 16:50:41 +02:00
Maurice 781c693434 feat(reservations): traveler badge and inline member/guest picker on booking cards (desktop) 2026-07-24 16:50:41 +02:00
Maurice 21456b983e feat(reservations): assign trip members and guests to bookings — backend (schema, migration, travelers endpoint) 2026-07-24 16:50:41 +02:00
Maurice 9344d4508c chore(deps): regenerate lockfile so json5 is the only change and the peer markers stay intact 2026-07-24 12:42:38 +02:00
fbnlrz 9ad3507e57 fix(llm-parse): tolerate non-strict JSON from Gemini and similar models
Gemini reached through the OpenAI-compatible endpoint returns
JavaScript-object-literal output — single-quoted strings, unquoted keys,
trailing commas — which strict JSON.parse rejects. The reservation list
then came back empty and the booking-import UI showed nothing (#1638).

Add a shared parseLenientJson() that tries strict JSON first and falls
back to JSON5 (which accepts exactly that relaxed superset), and route
both the OpenAI-compatible and Ollama-native parse paths through it,
replacing the two identical strict parsers.
2026-07-24 12:42:38 +02:00
Maurice 791468e5b3 fix(airtrail): also treat an endpoint-count > 2 booking as a local multi-leg shape, mirroring the server so a locally-grown chain is not mislabelled removed 2026-07-24 12:28:49 +02:00
fbnlrz 3dc9ac6ac6 fix(airtrail): stop labelling a merged multi-leg import "removed in AirTrail"
A multi-leg flight imported "as one flight with a layover" is detached
from sync by design — AirTrail has no single flight to round-trip a
layover chain to, so it is created with sync_enabled=0 (#1535). The
reservations badge collapsed every sync_enabled=0 AirTrail row into "Not
synced" with the tooltip "This flight was removed in AirTrail", which is
false for a merged booking that was never removed (#1646).

Give the multi-leg case (metadata.legs.length > 1) its own state: show
the normal AirTrail badge with a truthful tooltip explaining it stays a
one-time import. A genuinely removed single-leg flight still shows "Not
synced". Adds reservations.airtrail.layoverHint across all locales.
2026-07-24 12:28:49 +02:00
Maurice 68b0c6bceb fix(settings): mask inherited encrypted admin defaults so llm_api_key never reaches the client in cleartext 2026-07-24 12:26:58 +02:00
fbnlrz 4b73fa80a9 fix(settings): let an empty user value fall back to the admin default
The admin "user defaults" merge kept any user value that was merely
present, so an empty string shadowed the system-wide default. The
client's Settings save always writes every field — a blank Mapbox token
included — so a user who ever opened Map Settings got '' instead of the
admin token, and clearing their own token could never restore the
fallback (#1634).

Treat an empty/null user value for a defaultable key as "not set" when an
admin default exists, so it falls through. Non-defaultable keys and keys
without an admin default keep their exact stored value.
2026-07-24 12:26:58 +02:00
yu 617ffae351 feat(journey): add mobile quick capture with location and weather 2026-07-24 12:06:29 +02:00
Maurice 60abcf6598 feat(dashboard): drop the play circle from the beacon, keep the copy and the badge 2026-07-24 11:49:11 +02:00
Maurice 51f8afdb60 feat(dashboard): retire the 4.0.0 moment after Aug 23 — viewable-until badge + a permanent dismiss X 2026-07-24 11:49:11 +02:00
Maurice 2cec06161d fix(dashboard): drop the frame shake — the boom and the shatter carry the climax on their own 2026-07-24 11:49:11 +02:00
Maurice 0834282bfd feat(dashboard): five signature text moments + widget-matched corners
- The finale condenses out of light: ~1000 particles rise from the world and
  gather into the anthem's letterforms before the crisp DOM text takes over;
  the language cascade waits for the word to settle.
- Replaced fear-act lines decay letter by letter — the words of fear fall
  apart instead of vanishing.
- The climax boom rocks the whole frame for half a second, in sync with the
  sub impact and the border shatter.
- Hope-act lines reveal sentence by sentence, in speech rhythm.
- The standing anthem breathes — a barely-there scale and glow pulse.
- Beacon corners now use the dashboard tool widgets' --r-xl radius.
2026-07-24 11:49:11 +02:00
Maurice ffda4de77d feat(dashboard): the anthem IS the ending + beacon final polish + navbar fix
The show now ends standing: KEINE ANGST with the full 23-language cascade
stays up over the lit world, music carrying on until the traveler closes it —
no credits hand-off. Skip jumps straight to that standing frame.

Beacon: dismiss × removed, copy block centered on the canvas, the title gets
its quiet jewelry treatment — ivory-to-gold fill, a traveling sheen and a slow
breathing glow.

The overlay now portals to document.body: the sticky dashboard sidebar forms
its own stacking context, so the fixed navbar (z-200 at root) used to float
above the show regardless of the overlay's z-index.
2026-07-24 11:49:11 +02:00
Maurice 1cf1a9ca42 fix(dashboard): end screen drops the inspiration credit and the close button — anthem, three lines, done 2026-07-24 11:49:11 +02:00
Maurice 406229e701 fix(dashboard): flash words become stamped title cards — chromatic fringes, micro-jitter, hard cut, no zoom 2026-07-24 11:49:11 +02:00
Maurice d2d8b65827 fix(dashboard): soften the pivot into 'but you have traveled' + audio review fixes
The transition out of the fear act was too brutal: the silence now arrives as
a fast pull-away with the hall tail ringing out instead of a 100ms kill, the
pivot line trades its impact boom for a slow warm A1 swell (the first calm
breath), and the line itself breathes in over 3.4s.

Audio review fixes: the wind's swell LFO moves to a series tremolo stage so
the release can actually silence it before the cut (it used to warble through
the silence and click on stop); the heartbeat's noise layer becomes the
intended high-passed click instead of a dull lowpassed thud; dispose() pulls
the master to zero before the deferred context close so leaving the show
cannot pop.
2026-07-24 11:49:11 +02:00
Maurice ba2582f9c8 feat(dashboard): beacon goes cinema-poster — centered copy, iconic play circle
Copy sits centered on the night-world canvas over a corner-pooling scrim; the
title grows monumental tracking, a gold sheen and a soft glow; the text CTA
becomes a slowly pulsing golden play circle that fills on hover. The version
eyebrow and the red live dot are gone — title, one line, play. Nothing else.
2026-07-24 11:49:11 +02:00
Maurice 920628d63c feat(dashboard): show polish — readable lines, gliding arcs, a real hall
- Lines get a layered soft glow plus a radial pool of darkness under the text
  zone, so they hold up over the burning border map without a hard box.
- Arc heads now interpolate fractionally along the curve with ease-out growth
  (the whole-segment quantization read as stutter), in the show and the beacon
  teaser alike.
- The score gains a convolution reverb with a procedurally generated impulse
  response; the fear act trades raw saws for an organic bed (sine sub,
  drifting fifth, breathing noise rumble, a semitone grind in the dread act),
  the shimmer runs through a feedback echo, impact tails bloom in the hall,
  the heartbeat stays close and dry.
2026-07-24 11:49:11 +02:00
Maurice 10bfad97da feat(dashboard): cinematic beacon — living arc teaser, sheen title, scrim
The beacon is now a dark cinema card: a small canvas paints a breathing
night-world with golden travel arcs on loop, colored glows drift over it, the
title carries a gold sheen sweep and the copy sits on a bottom scrim. Core
colors ride inline so no dashboard widget CSS can wash the card out again.
2026-07-24 11:49:11 +02:00
Maurice c541c158df fix(dashboard): keep the generic line up through the stats slot for travelers with no places yet 2026-07-24 11:49:11 +02:00
Maurice aec541c573 feat(dashboard): make the KEINE ANGST show hit harder
Visual: city lights now come alive in the opening and die one by one while the
borders burn; three staccato words strobe with the heartbeat before the cut;
at the climax the borders no longer fade — they shatter into drifting sparks.
The traveler's own places ignite gold in the release act, with their real
numbers in the line ('{p} places. {c} countries. And not once did the world
hurt you.'). All heavy geometry is decimated to a vertex budget and pre-baked
into offscreen layers, so the frame path is a handful of drawImage calls — the
per-frame border stroke that dropped the show to ~1 fps is gone.

Sound: heartbeat with a click transient, wind textures, a doubled noise riser,
cue-driven sub-bass impacts and a gliding D–A–f#m–E progression with a sub
root under the anthem instead of the static pad.

Also: dashboard FAB hidden behind the show, suspended-AudioContext recovery
(Safari), tab-hidden pause for clock and audio, aborted geo fetch on close,
valid beacon markup (dismiss as sibling button), RTL-aware beacon, real
reduced-motion static frame, and native-speaker fixes for nl/sv/br/fr/it/es/
tr/gr copy.
2026-07-24 11:49:11 +02:00
Maurice c6d96872ae feat(dashboard): 4.0.0 release moment — KEINE ANGST
A one-release statement against racism, hatred and fascism, told the way TREK
knows the world: through travel. A pulsing beacon in the dashboard sidebar
(desktop only, dismissible) opens a ~80s full-screen show — the world as a map
of dots whose borders burn red and then dissolve under a growing web of travel
connections, driven by a fully procedural Web Audio score (heartbeat, drone,
A-major release; no audio assets, no copyrighted material) and hard-cut lines
in all 23 TREK languages, closing on the anthem cascade and a credit to the
song that sparked it (Danger Dan — Keine Angst).

Self-contained in components/FourZero (own copy dictionary, no shared i18n
churn); wired into the dashboard with two marked lines. Removing the folder
plus those two lines retires the moment after the release.
2026-07-24 11:49:11 +02:00
dependabot[bot] eb0dfc83c1 chore(deps): bump hono from 4.12.26 to 4.12.31
Bumps [hono](https://github.com/honojs/hono) from 4.12.26 to 4.12.31.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.26...v4.12.31)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.31
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 11:38:38 +02:00
Konstantinos Thermos e100c52676 fix(maps): localize map POI pin titles to the user's language 2026-07-24 11:36:30 +02:00
Konstantinos Thermos fce26618a1 fix(transport): guard non-finite dates in trip-map duration 2026-07-24 11:36:24 +02:00
Elmer 7bd9e08016 feat(vacay): add school holiday overlays (#1633)
* feat: add vacay school holiday overlays

* fix(vacay): remove SOH control character corruption in locale files

13 of 22 vacay locale files contained a 0x01 (SOH) control character
that corrupted the closing }; token. This broke the shared package
build (tsdown/rolldown PARSE_ERROR: Invalid Character) and cascaded
to all client tests failing to resolve @trek/shared.

Affected locales: ar, br, ca, cs, es, fr, gr, hu, id, it, ja, ko,
nl, pl, ru, sv, tr, uk, vi, zh, zh-TW

* fix(vacay): school-holiday migration order, i18n dupes, settings layout

- Move the school-holiday columns migration to the end of the migrations
  array. It sat before the #1435/#1281 migrations, which every existing DB
  had already run, so the runner skipped it and school_holidays_enabled /
  vacay_holiday_calendars.type were never created — PUT /addons/vacay/plan
  then 500'd with "no such column".
- Remove duplicate i18n keys in 13 locale vacay files (TS1117) that failed
  the shared + client typechecks.
- Move the school-holidays block into the right column, directly under
  public holidays, instead of full-width below everything.
- Add the school-holidays section to the mobile Vacay settings sheet.

* feat(vacay): modern school-holiday day styling + combined hover tooltip

- Replace the stacked 2px underlines with a single rounded accent band and a
  soft background wash on plain school-break days (desktop + mobile — mobile
  did not render school holidays at all before).
- Fold school holidays into the custom day tooltip under a divider instead of a
  separate native title, and show the tooltip on every set day (whole vacation
  days too), not only half days / shared calendars.

---------

Co-authored-by: Otto <uif86164@automotive-wan.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-07-23 22:48:42 +02:00
SimMesg20 4d3d16389e Feat/journey external picture provider rework (#1632)
* feat(journey): add contextual external photo browser

* fix(Journey) added missing language keys for new Journey feature

* fix(Journey) fixing missing gps requests aswell as added protection against duplicate entries
2026-07-23 21:35:12 +02:00
Maurice 8855b5581d feat(places): planned pool filter + hotel-transfer day routing (#1666)
* fix(dayplan): expose route tools on a hotel-to-hotel transfer day (#1297)

* feat(places): add a Planned filter to the places pool (sidebar, map, mobile)

* fix(places): stretch the mobile pool filter chips to fill the row
2026-07-23 21:28:00 +02:00
Maurice c5360be242 feat(dayplan): choose the travel mode per segment (#1281) (#1651)
* feat(dayplan): persist per-leg and per-day travel mode (#1281)

The day-plan route used one transport mode for the whole day, held only in
ephemeral state. Store it instead: leg_transport_mode on an assignment is
the mode of the leg leaving that stop (null = inherit the day default), and
days gains a default_transport_mode. Both nullable, so existing itineraries
are unchanged.

Adds PUT /assignments/:id/transport and PUT /days/:id/transport — their own
endpoints so setting the day default can't wipe notes/title the way the
general day update would — mirroring the assignment_time path.

* feat(dayplan): choose the travel mode per segment (#1281)

Each leg between two stops now routes with its own mode, so a day can mix
walking, driving and plugin-routed legs. Tap the connector between two
places to change that leg's mode (desktop popup / mobile sheet); the
whole-day Foot/Car picker sets the persisted day default and leaves
explicitly-set legs alone. The connector and map redraw in the chosen mode,
and each route segment is tagged with the mode it was drawn in.

New strings across all 23 locales.
2026-07-21 23:36:53 +02:00
Maurice ac723744ab feat(collections): add places by coordinates + sort A–Z (#1647)
* feat(collections): add places by coordinates and sort the list

Raised in #1640: collections behaved differently from trips — you
couldn't add a place by typing coordinates, and the list had no order
control.

The add-place modal and the place detail sheet now take an editable
address plus latitude/longitude, with a "lat, lng" paste shortcut, so a
place can be saved by GPS without searching. The place update path
learned to persist coordinates, so an existing one can be corrected
later.

A sort control in the filter row toggles between the saved order and
alphabetical by name. The add-place button moved into that row to match
the other controls, and the add-label button dropped its dashed one-off
styling. New strings land across all 23 locales.

* feat(collections): make the add-place button icon-only

Keep the filter row compact — the button carries just the plus icon (with
an aria-label/title), squared off to match the dropdown height.
2026-07-21 22:10:41 +02:00
Maurice 399e3573c1 feat(places): collaborative place ratings + collections MCP (#1435) (#1644)
Trip members can rate a place 1-5 stars; the place shows the average,
with a who-voted custom tooltip and a star-sort toggle in the places
sidebar. Saved collection places are ratable too (plus a min-rating
filter), and ratings travel with a place copied between a trip and a
collection — carrying only the votes of users who belong to both sides.

Adds a full Collections MCP surface (list/get, CRUD lists + places,
status, rate_collection_place, labels, sharing, copy-to-trip) under a
new collections:read/collections:write scope, plus rate_place for trip
places; rating aggregates flow through the existing place outputs.

Mobile trip + collection place sheets gain the rating row. New UI
strings translated across all 22 locales.
2026-07-21 21:00:26 +02:00
Konstantinos Thermos 88cb7f22dd fix(budget): match the add-expense input decimal separator to the list (#1645) 2026-07-21 20:36:47 +02:00
Maurice b8728078c4 feat(reservations): add a parking booking type (#1444) (#1642)
* feat(reservations): add a parking booking type (#1444)

* feat(reservations): parking type option, icon and color across the UI

* test(reservations): cover the parking booking type

* docs(reservations): document the parking booking type
2026-07-21 16:02:53 +02:00
Maurice 3678761a2d feat(places): custom place image (#1136) (#1641)
* feat(places): upload a custom place image on the server (#1136)

* i18n(places): custom place image labels

* feat(places): click a place thumbnail to set a custom image

* feat(places): custom place image on mobile place and collection sheets

* test(places): cover custom place and collection image upload

* docs(places): document custom place images

* fix(places): carry osm_id on the embedded place so day-plan thumbnails auto-fetch (#1136)

* feat(places): custom tooltip on the place image controls

* fix(places): show a custom place image on map markers, skip the redundant photo fetch (#1136)
2026-07-21 15:33:31 +02:00
Maurice 31f6ec1f47 feat(vacay): read-only calendar sharing (#444, #667) (#1637)
* feat(vacay): read-only calendar shares on the server (#444, #667)

* feat(vacay): shared calendars card and ring overlay on desktop

* feat(vacay): share sheet and calendar rings on mobile

* test(vacay): cover the calendar share flows

* docs(vacay): document read-only calendar sharing
2026-07-21 13:46:38 +02:00
Maurice 7528706d00 feat(vacay): half vacation days (#552) (#1631)
* feat(vacay): support half vacation days (#552)

Log a vacation day as a half day (0.5) as well as a full day and count it
as such against the entitlement. A "Half day" mode joins the calendar
toolbar on desktop and mobile; half days render as a diagonal half-fill in
the day cell, and the used/remaining totals carry the fraction.

Closes #552

* refactor(vacay): half-day as a per-person toggle with a corner badge

The diagonal half-fill collided with the two-person split — one person's
half day looked like two people off. Drop the diagonal: the cell fill keeps
showing who is off, a half day gets a small corner ½ badge, and a hover
tooltip lists who is off on that date and how much (only when a half day is
present). Logging moves from a third mode to a Half-day toggle that applies
to the selected person, on desktop and mobile.

* feat(vacay): mark half days with an orange corner dot

Swap the ½ badge for a small orange dot in the bottom-right corner, mirroring
the blue trip dot — quieter in the grid, and the hover tooltip still spells
out who is on a half day.

* fix(vacay): make the fraction migration idempotent

The crosswalk test replays the migration tail (rewind + re-run), which expects
every later migration to be idempotent. Guard the vacay_entries ADD COLUMN
behind a pragma_table_info check so the second run is a no-op. Also only set
dayVisual's `half` flag when true, so full days keep their exact
{ background, numColor } shape for the mobile day-model unit test.

* style(vacay): nudge the mobile day dots slightly inward

The blue trip dot and orange half-day dot sat right in the cell corner on
mobile; move both a touch toward the centre so they read cleaner.
2026-07-20 23:38:47 +02:00
Konstantinos Thermos a3cb783df7 fix(settings): retry settings load on reconnect so region prefs stop resetting (#1626) 2026-07-20 21:02:19 +02:00
Maurice b6a3cced43 Comprehensive mobile UI rewrite + desktop Vacay & Journey redesign (#1577)
* 3.3.0 (#1472)

* feat(plugins): grow the frame bridge — fill pages, confirm, openExternal, live context

- page/trip-page hosts pass fill: the frame pins to 100% height and ignores
  trek:resize, so a kit plugin's auto height report no longer collapses a full
  page into a floating island with dead space below (widgets keep self-sizing)
- trek:confirm renders the native ConfirmDialog host-side (the sandbox has no
  allow-modals) and answers trek:confirm:result; one at a time
- trek:openExternal opens validated http(s) URLs in a noopener tab — the
  sandbox has no allow-popups, so plugins simply couldn't link out before
- trek:notify accepts an optional duration, clamped to 1.5-15s
- context gains dir (rtl/ltr) and is re-pushed when locale or format settings
  change, not just on appearance mutations
- core events for the trip in view are forwarded as trek:event — names only,
  never payloads, mirroring the server-side events surface

* fix(plugins): move trip warnings out of the content area

The warning pills overlaid the top of every planner tab at full width, sitting
on the map and its toolbar. Now a warning from a plugin that owns a trip-page
tab renders as a compact chip in the navbar centre (click jumps to the tab; the
navbar centre is free on trip pages), and everything else floats above the
content at the bottom instead. Mobile keeps all warnings in the bottom overlay
since the desktop navbar isn't there. The trip tab's frame also opts into the
new fill mode.

* feat(plugin-sdk): 1.4.0 — motion library + new bridge helpers in the kit

Mirrors the host's animation vocabulary 1:1 into TREK_UI_CSS (menu/popover/
modal/backdrop/toast enters, drawer variant under 640px, page-enter, stagger,
skeleton shimmer, chart reveals) including the reduced-motion degrade to a
gentle fade. window.trek grows confirm(), openExternal(), onEvent() and a
notify duration, and applyContext now stamps lang/dir on the document so RTL
hosts get RTL plugin UIs.

* feat(plugins): surface registry download counts in browse

The registry now aggregates GitHub release download counts per plugin
(TREK-Plugins#18) as an entry-level downloadCount. Project it through
browse/detail and show it as a compact stat on the browse cards and in the
detail meta grid. Counts are raw asset downloads (updates and CI included),
so the UI says downloads, not installs.

* docs(plugins): document the grown bridge surface and motion classes

* fix(plugins): harden the new bridge paths

Review pass over the bridge additions:

- keep the unstable useToast() object out of the effect deps (ref instead) —
  it re-created the effect on every parent render, and with the new live
  repost that meant a trek:context flood into the frame
- reset loads/height/confirm state and key the iframe when a host swaps
  pluginId in place (tab bar, /plugins/:id) — the new plugin's document was
  refused as a 'navigated' frame and every kit promise hung
- confirm dialogs always lead with the host-controlled plugin name so a
  plugin can't dress its dialog up as a TREK system prompt; answer/refuse
  moved out of setState updaters (StrictMode ran them twice)
- Number.isFinite on the notify duration (NaN parked a sticky toast)
- don't forward other plugins' namespaced broadcasts as trek:event; a
  plugin's own plugin:{id}:* broadcasts now reach its frame though
- teach the SDK dev preview the confirm/openExternal contract so
  trek.confirm() resolves in /preview
- 999,950 downloads formats as 1M, not 1000k

* fix(planner): let plugin warning chips grow wider before truncating

The nav-centre chip capped at 340px, so a longer warning (e.g. the TREK x
Japan weather prompt) was ellipsised almost immediately. Scale it with the
viewport up to 520px so most messages read in full while still yielding on
narrow desktops.

* feat(plugins): sort the plugin browser by download count

Discover now honours the sort dropdown (it was always alphabetical) and adds
a 'Most downloads' option that ranks the registry by downloadCount. The sort
keys are scoped per tab — updates-first stays with Installed, most-downloads
with Discover — and snap back to name when the tab can't offer them.

* feat(plugin-sdk): auto-upgrade native <select> to a host-styled dropdown

A sandboxed plugin can't reach the host's components, and a native <select>
draws its popup from the OS — so plugin dropdowns never matched TREK. The design
kit now enhances every <select> into a keyboard-accessible listbox that uses the
kit tokens, keeping the real element as the value/form source (it still fires
change). Authors write a plain <select> and get the host look for free; opt a
field out with data-trek-native. validate warns when a plugin ships a <select>
without inlining the kit.

* feat(plugins): add issue url link

* feat(plugins): reservations write + cross-trip reads

- db:write:reservations -> reservations.create/update/delete, gated exactly like
  the REST/MCP path (reservation_edit + trip membership, acting user host-bound,
  no impersonation) and delegating to ReservationsService so the accommodation,
  budget-sync, booking-notification and reservation:* broadcasts match the web
  app 1:1 — a booking/flight/import plugin can finally write a reservation
- trips.listMine / reservations.listMine: enumerate every trip and booking the
  acting user can access (membership baked into listTrips, never a raw
  cross-tenant SELECT) — dashboards/aggregates were impossible before
- audit: derive auditability from METHOD_PERMISSION so a new capability method
  can't be added un-audited by omission
- typed ctx.reservations.* / ctx.trips.listMine, perm label (en/de), wiki

* feat(plugins): read scopes for journal, atlas, vacay and day notes

- db:read:journal / db:read:atlas / db:read:vacay expose the acting user's OWN
  journals / visited countries+regions / vacation plan across all their trips
  (user-scoped like costs.listMine, each gated on its addon being enabled),
  reusing the addon's existing readers
- db:read:daynotes -> daynotes.list(tripId, dayId), trip-scoped and
  membership-checked like the other trip reads
- typed ctx.journal / atlas / vacay / daynotes, perm labels (en/de), wiki,
  audit resource labels, tests

* feat(plugins): day notes write scope

- db:write:daynotes -> daynotes.create/update/delete, gated under the app's
  'day_edit' permission (like days) with the day verified to belong to the trip;
  reuses dayNoteService and broadcasts the same dayNote:* events so open
  sessions update live
- typed ctx.daynotes.create/update/delete, perm label (en/de), wiki, tests

* feat(plugins): run declared background jobs on a schedule

- plugins already declared jobs {id, schedule} but the cron was never wired. The
  host now schedules them: host-entry reports each job's schedule, the supervisor
  starts the jobs (node-cron) when the plugin goes active and stops them on
  kill/deactivate so nothing leaks
- opt-in via a new jobs:run permission — scheduled work runs with NO acting user
  (its trip reads stay refused; it can only use ctx.db and declared egress), so
  background execution is a distinct, admin-granted capability. Invalid crons are
  skipped and a throwing job can't break the host
- extracted a small, unit-tested scheduler (plugin-jobs.ts); perm label (en/de),
  wiki, tests

* feat(plugins): read scope for saved-place collections

- db:read:collections -> collections.listMine() / collections.get(id): the acting
  user's own collections (user-scoped, gated on the Collections addon), reusing
  collectionsService
- typed ctx.collections, perm label (en/de), wiki, audit resource labels, tests

* fix(plugins): translate new permission labels to all locales + cover the new wiring

- add the 8 new admin.plugins.perm.* labels (reservations/day-notes writes, the
  journal/atlas/vacay/day-notes/collections reads and jobs:run) to the remaining
  20 locales so the strict i18n key-parity test passes again
- cover the create-rpc-host reservation / day-note / cross-trip / addon-read deps
  (the real side-effect wiring the mocked rpc-host tests don't exercise) so the
  src/nest 80% branch-coverage gate holds

* feat(plugins): dev-link — hot-reload a local plugin against real data

Answers a plugin developer's ask: today you either get `trek-plugin-sdk dev`
(fast hot-reload but MOCK/fixture data) or the full build->pack->upload->activate
cycle (real data, no watcher). Neither gives "local dir + hot-reload + real data".

- POST /admin/plugins/link registers a plugin from a LOCAL built directory by
  symlinking it into the plugins volume — the loader already forks the resolved
  real path, so ZERO loader change — and registering it INACTIVE as `local:link`.
  Validates the manifest + refuses native binaries exactly like a sideload.
- POST /admin/plugins/:id/reload re-forks a linked plugin via the existing
  deactivate->activate primitive (same grants, no re-consent unless the manifest
  widened perms). A best-effort fs.watch auto-reloads on rebuild.
- It runs through the UNCHANGED capability RPC host: real, membership-gated data,
  acting user host-bound, no impersonation — code origin never touches the gate.
- Gated behind TREK_PLUGINS_DEV_LINK on top of admin + kill-switch, because a
  linked plugin bypasses the install-time signature model and, under `npm run
  dev`, the OS jail is off. Off by default; never reachable in production.
- discovery follows a symlinked <root>/<id>; uninstall/link never delete the
  author's source (link-safe removal for POSIX symlinks and Windows junctions).

* docs(plugins): document the dev-link real-data hot-reload workflow

Adds a "Test against a real instance's data (dev-link)" subsection next to the
mock-data SDK preview: TREK_PLUGINS_DEV_LINK, POST /link with a local built dir,
activate + consent, hot-reload via the file-watch / POST /:id/reload / Restart,
and the dev-only security caveats.

* feat(plugins): dev-link admin UI

- surface devLink (TREK_PLUGINS_DEV_LINK) in GET /admin/plugins so the panel shows
  the link form only where dev-link is enabled
- AdminPluginsPanel: a "Link a local plugin" form (path -> POST /link), a Dev-Link
  badge for source_repo=local:link, and adminApi.pluginLink/pluginReload
- fix: the plugin menu treated any non-local:upload source_repo as a GitHub repo,
  so a dev-linked plugin rendered github.com/local:link links — exclude local:link
- labels for the 6 new dev-link UI strings across all 22 locales

* docs(plugins): document the dev-link admin UI

The dev-link section showed only the curl call — surface the Admin → Plugins
"Link a local plugin" field (the primary path) and the Dev-link badge, with curl
kept as the scripting alternative.

* feat(plugins): enrich core events with { entity, entityId }

Subscribed plugins now learn WHICH entity changed, not just the event name — a
reservation/place/day/... id derived host-side from an explicit per-family
whitelist. Threaded through the six event hops WITHOUT touching actingUserId: the
handler still runs with no user, so the id is not dereferenceable (a trip read is
still refused; the id says what to react to, not what it contains). A non-entity id
can never surface — budget:member-paid-updated yields the itemId, never the userId
— and bulk/reorder/sub-entity payloads carry no id. The mapper is pure, synchronous
and never throws into the core broadcast. No new permission (reuses
events:subscribe); backend-only.

* feat(plugins): packing write scope with #858 privacy-scoped broadcasts

- db:write:packing -> packing.create/update/delete, gated under the app's
  'packing_edit' permission (like the REST path) with the host-bound acting user
  as owner; reuses packingService
- replicates the packing privacy model 1:1 (the controller/service helpers aren't
  exported): create/delete fan out to the item's viewers only (owner + recipients,
  or the whole room for a Common item); update runs the four public<->private
  transitions, dropping a freshly-privatized item from the room BEFORE re-adding it
  owner-only so it never leaks. A stale write is BAD_PARAMS with no broadcast
- typed ctx.packing.create/update/delete, perm label (22 locales), wiki, tests
  (rpc-host gating + the four transitions + owner-scoped delete)

* feat(plugins): tableContributor hook — host-rendered view columns/actions (backend)

The registry backend for plugin-contributed columns/actions in the native planner
views (the tabular-reservations use case), mirroring placeDetailProvider:
- hook:table-contributor + the tableContributor hook (getContributions(view,
  tripId, ctx) -> TableContribution[]), double-gated (implement + grant) like the
  other provider hooks
- GET /api/view-contributions/:view/:tripId — view whitelist + membership gate +
  per-provider timeout/fail-safe, plus the hardening the older provider hooks lack:
  every field is String-coerced + length-capped, kind/tone/target enum-whitelisted,
  per-provider counts capped (<=20 columns / <=10 actions), and a column url must be
  http/https/mailto (a javascript:/data: url is click-XSS into the native DOM)
- typed pluginsApi.viewContributions + the ViewContribution union, perm label
  (22 locales), wiki, hardening tests

* feat(plugins): render tableContributor columns/actions in the reservations view

The frontend for the tableContributor hook: a reusable PluginContributions layer
(usePluginViewContributions + PluginColumns/PluginActions) that renders the
host-normalized column/action leaves NATIVELY — a column is text/badge/link, an
action is a button that calls the plugin route or opens its sandboxed frame in a
modal (plugin markup only ever runs inside the opaque-origin iframe). Wired into the
reservations cards (both ReservationCard and TransitJourneyCard) as a strictly-
additive footer keyed by reservation id: zero change to a card when no plugin
contributes. Fetched once per view, fail-safe.

* docs(plugins): bring the permissions wikis current with this cycle

Plugin-Permissions.md was missing every permission added this cycle — add rows for
the read scopes (journal/atlas/vacay/daynotes/collections), the write scopes
(reservations/daynotes/packing, packing noting the #858 owner-scoping), jobs:run
and hook:table-contributor, and correct the events:subscribe row for the new
{ entity, entityId } hint. Add the jobs:run row to Plugin-Development.md too.

* fix(maps): stop quick one-finger pans zooming the map on mobile (#1440)

The global drag-drop-touch polyfill installs document-level touch listeners
on phones. On every single-finger touchend it records a timestamp, and if the
next touch starts within 500ms it synthesises a dblclick on the target, which
the map's default double-click-zoom turns into a zoom-in. Two quick one-finger
pans therefore zoomed instead of panning.

The polyfill only bridges HTML5 drag-and-drop to touch for planner reordering,
which is already disabled on mobile (#1432), so gate its import to viewports
>=1024px (the lg breakpoint useIsMobile uses). Removes the phantom-dblclick
source on phones while keeping touch DnD on large viewports; fixes both the
Leaflet and GL renderers.

* fix(feeds): emit TZID + VTIMEZONE so subscribed calendars respect time zones (#1453)

exportICS emitted timed DTSTART/DTEND as bare floating times (no Z, no TZID),
which iOS/Google Calendar render in the subscriber's local zone instead of the
zone TREK shows. Resolve an IANA zone per timed event — transport endpoints use
their stored timezone (departure drives DTSTART, arrival drives DTEND), while
assignments and hotel/restaurant reservations derive it from place coordinates
via tz-lookup — and attach TZID backed by a VTIMEZONE component. The all-trips
feed now carries deduped VTIMEZONE blocks so TZID references still resolve.

* feat(plugins): render tableContributor contributions in the places + day views

Extends the tableContributor frontend to all three planner views: hoist the shared
PluginCardFooter into PluginContributions, wire the places sidebar (keyed by place
id, rendered as a sibling after each row so the drag/scroll row stays untouched)
and the day panel (keyed by day id, guarded for a null day). Strictly additive +
fail-safe like the reservations view — nothing renders when no plugin contributes.

* fix(vacay): source holiday subdivisions from ISO 3166-2 so all states show

The state/region picker for public-holiday calendars was built from the
union of each holiday's counties for the current year, so a subdivision
only appeared if some holiday that year was tagged with it. States with
no state-specific holiday (e.g. US-WA, and AR/FL/NV/WY in 2026) silently
vanished, blocking calendar creation (#1456).

Source the full, correctly-named subdivision list per country from
ISO 3166-2 instead, merged with any nager county code ISO lacks. Only
region-partitioned countries get a picker, so nationwide-only countries
keep allowing a country-level calendar. No server change needed —
selecting a state already yields federal holidays via applyHolidayCalendars.

* fix(costs): settlements honor custom per-member splits (#1458)

calculateSettlement read each member's custom split amount but its query
never selected budget_item_members.amount, so hasCustomSplit was always
false and every settlement fell back to the equal split. Select bm.amount
so custom amounts drive the balances.

Also blank the Overview 'Per Person' / 'Per Person·Day' columns and CSV
for custom-split items, where a single averaged figure is meaningless.

* feat(plugins): read-convenience + todos + packing bags + tags + roster

A wave of small, high-value capabilities:
- weather:read (ctx.weather.get) — the host's cached forecast, tenant-free
- db:read:categories (ctx.categories.list) — the global place-category list
- db:read:tags / db:write:tags (ctx.tags) — the acting user's own tags, ownership
  re-checked before each write
- trips.members (ctx.trips.members) — the trip roster (id + display fields),
  membership-checked
- db:read:todos / db:write:todos (ctx.todos) — a trip's to-dos, gated by the app's
  packing_edit like the REST path, broadcasts todo:*
- packing bags on ctx.packing (listBags/createBag/updateBag/deleteBag/setBagMembers)
  under db:write:packing — no privacy, plain room broadcasts
perm labels (22 locales), both wikis, rpc-host gating + create-rpc-host wiring tests

* fix(dashboard): render next-trip boarding pass stats on Safari (#1459)

The boarding-pass bar carved its ticket-stub notches with a two-layer
radial-gradient mask composited via mask-composite: intersect (and legacy
-webkit-mask-composite: source-in). Safari mis-composites that multi-layer
path to fully transparent, hiding the entire stats bar while Chrome renders
it fine.

Split .hero-pass into an outer wrapper (left notch) and a .hero-pass-inner
glass panel (right notch), each carrying a single-layer mask so the
mask-composite path is never exercised. Renders identically across engines
and degrades safely where mask-image is unsupported.

* feat(plugins): write scopes for atlas, vacay, journal and collections

The write half of the user-scoped addon reads:
- db:write:atlas -> ctx.atlas.markCountry/unmarkCountry/markRegion/unmarkRegion +
  bucket-list create/delete. Every row is the acting user's own (visited_countries/
  visited_regions/bucket) — no trip scoping, no cross-tenant surface. Unblocks
  AirTrail-style two-way sync (#214)
- db:write:vacay -> ctx.vacay.toggleEntry/toggleCompanyHoliday. The plan is
  resolved HOST-SIDE from the acting user's active plan — a plugin can never name
  another plan, and toggleEntry only toggles the acting user's own PTO day
- db:write:journal -> ctx.journal.createEntry/updateEntry/deleteEntry, self-gated
  by journeyService.canEdit (owner/contributor) against the acting user
- db:write:collections -> ctx.collections.create/update/savePlace/copyToTrip/
  deletePlace, schema-validated; the service's per-collection role checks
  (assertAccess 404 / assertCanEdit 403) map onto RESOURCE_FORBIDDEN
All addon-gated, userless contexts refused, audited. Perm labels (22 locales),
both wikis, gating + wiring tests.

* fix(admin): name the Costs add-on consistently in the catalog

The budget add-on catalog entry still resolved to 'Budget' while the
feature is labeled 'Costs' everywhere else (trip tab, navbar). Align
admin.addons.catalog.budget.name with each locale's trip.tabs.budget
label. Closes #1464

* feat(plugins): file attach, collab content and gated member-add

- db:write:files -> ctx.files.create/createLink/update/softDelete under the app's
  separate file_upload/file_edit/file_delete rights. Content arrives as bounded
  base64 (10MB decoded cap, well under the app's 50MB), the extension is validated
  against the central blocklist BEFORE anything touches disk, and link targets
  must live on the same trip (findForeignLinkTarget). Broadcasts file:*
- db:write:collab -> ctx.collab.createNote/createPoll/votePoll/createMessage
  under collab_edit + the Collab addon, emitting the same collab:* events as the
  app; service-reported errors surface as BAD_PARAMS
- db:write:members -> ctx.trips.addMember. Adding a member GRANTS TRIP ACCESS, so
  it is deliberately its own permission behind the app's member_manage right
  (default: trip owner only) and never bundled with a lower-risk write; the acting
  user is recorded as the inviter, target must exist, owner/duplicate adds no-op
Perm labels (22 locales), both wikis, gating + wiring tests.

* fix(maps): honor check-in/out times for hotel bookend legs (#1465)

The day route drew the accommodation as the day's start/end whenever the
edge stop was a place, ignoring the morningIsSleptHere/eveningIsOvernight
provenance already computed by getDayBookendHotels. On a check-in day an
airport placed before check-in got a spurious hotel -> airport leg, and on
a check-out day a later "home" stop still got a home -> hotel return leg.

Add time-aware shouldDrawMorningLeg/shouldDrawEveningLeg helpers: the
morning leg is the home-base default on a check-in day but is dropped when
the first place is timed before check-in; the evening return leg is off on
a check-out day unless the last place is timed at/before check-out. Wire
them into the map polyline, the sidebar hotel connectors, and the Google
Maps export so all three stay consistent.

* feat(plugins): host-mediated notifications and LLM access

Two host-owned integration primitives — the plugin supplies intent, the host
owns the sensitive part:

- notify:send -> ctx.notify.send({title, body, link?, scope, targetId}). Delegates
  to notificationService.send with a new plugin_notification event (raw title/body
  carried as passthrough params), so recipient resolution, channel fan-out
  (bell inbox + email/ntfy/webhook) and per-user preferences all match core 1:1.
  Recipients are FORCED to the acting user (scope 'user', targetId === uid) or a
  trip they belong to (scope 'trip'); scope 'admin' refused; the in-app link must
  be a relative /path (open-redirect-safe). No arbitrary recipient, no impersonation.
  Users can mute plugin notifications like any other event.
- ai:invoke -> ctx.ai.complete(prompt) / ctx.ai.extract(text, jsonSchema). Runs the
  admin/user-configured provider via resolveLlmConfig + the existing extraction
  client under the acting user; the host holds the (encrypted) key, the plugin
  never sees it. Refused when no provider is configured; 20k-char caps. Output is
  DATA (complete -> {text}, extract -> {results}) and never auto-written, so
  prompt-injection can't reach a write without the plugin's own gated call.

plugin_notification wired through the shared NotificationEventKey + all 22 locales
(inbox passthrough + external channels). Perm labels (22 locales), both wikis,
gating + wiring tests.

* fix(budget): offer every Frankfurter-supported currency (#1470)

The cost currency picker was gated by a hardcoded 47-code list, so
currencies the app can actually convert (OMR, CRC, UGX, MKD, ALL, and
~115 more) couldn't be selected. Replace CURRENCIES/SYMBOLS with the full
set the Frankfurter v2 FX API supports (archived BGN/HRK dropped), unify
the dashboard offline fallback onto it, and teach currencyDecimals about
the newly reachable zero- and three-decimal currencies. A currenciesWith
helper keeps a previously saved (now-archived) selection selectable so it
isn't silently wiped.

* feat(plugins): tableContributor into the costs, packing and files views

Extends the shipped tableContributor hook to three more native views — no new
permission, no new attack surface: the same host-normalized, length-capped,
url-allowlisted (http/https/mailto), enum-bounded, fail-safe pipeline, just more
render sites.

- server: add costs/packing/files to the view-contributions whitelist
- client: widen the ViewName union + the api view type; render PluginCardFooter
  keyed by entityId in the budget category table (a colSpan footer row per item),
  the packing category group (footer after each item row, drag untouched) and the
  files list (footer after each row)

A currency plugin can now drop a converted-amount column onto a cost row, a
receipts plugin a 'view receipt' action onto a file, etc. Controller test asserts
the three new views are accepted; both wikis updated.

* fix(pdf): repeat day header on overflowing itinerary export pages (#1471)

* feat(plugins): map-marker provider hook — plugins can overlay trip-map markers

New declarative provider hook `mapMarkerProvider` (#587 "show bookings on map",
the single most-requested contribution class, with zero contribution point until
now):

- hook:map-marker-provider permission + MapMarkerProvider/MapMarkerContribution SDK
  types + HOOK_PERMISSION wiring
- GET /api/map-markers/:tripId (MapMarkersController) mirrors the view-contributions
  hardening: membership-gated, providers invoked host->plugin on a 5s timeout,
  fail-safe. Every field normalized server-side — coordinates range-checked
  (-90..90 / -180..180), strings String-coerced + length-capped, icon/tone enum-
  whitelisted, popup url http/https/mailto only (a javascript:/data: url would be
  click-XSS), marker count capped at 200 per plugin
- client: PluginMapMarkers layer renders the markers as plain Leaflet Marker+Popup
  inside the trip map; plugin JS NEVER runs on the map canvas, every value is
  host-vetted data. Threaded tripId through MapView; fail-safe fetch

Declarative-only by design, mirroring placeDetailProvider/tableContributor. Perm
label (22 locales), controller hardening test, both wikis.

* feat(plugins): show page plugins in the mobile bottom nav

Page plugins were reachable from the desktop nav pill (Navbar) but not the mobile
tab bar — you had to type /plugins/:id. BottomNav now reads page plugins from the
plugin store and appends them the same way global addons are, mirroring Navbar.
One-file client nav wiring; no new capability surface.

* feat(plugins): per-user plugin settings form + ctx.settings runtime read

Users can now enter their own per-plugin config (an API key, a preference) —
the prerequisite for almost every real integration, previously unreachable
(scope:'user' settings were only listed read-only in the admin panel).

- migration: plugin_user_config (plugin_id, user_id, config JSON) — each user's
  own values, separate from the admin-owned instance plugins.config
- PluginsService.getUserConfig / updateUserConfig / getUserConfigDecrypted +
  readUserSettingDecrypted: secrets encrypted at rest (apiKeyCrypto), masked to
  the client, an unchanged secret (the mask) keeps its stored ciphertext, and only
  DECLARED scope:'user' keys are ever stored
- GET/POST /api/plugin-settings/:id (PluginUserSettingsController) — its own
  user-gated path (not the admin surface, not the /:id/* proxy), JwtAuthGuard only,
  scoped to the acting user
- runtime: ctx.settings.get(key) -> the acting user's decrypted value (unconditional
  RPC, not sensitive cross-tenant; userless job/onLoad gets undefined)
- client: a Plugins tab in Settings host-renders each active plugin's scope:'user'
  fields as an editable form (secrets write-only), reusing the declarative field
  shape — no plugin markup executes

i18n (22 locales), wiki, rpc-host + service + masking/encryption tests.

* fix(journey): keep skeleton suggestions in sync with linked trip places (#1473)

Journey skeleton suggestions mirror a linked trip's day-assigned places, but
sync relied on scattered per-event hooks that several assignment mutation paths
never called: unassign, move and time-change fired nothing, no remove-on-unassign
capability existed, and every MCP assignment tool synced nothing. Skeletons drifted
from the trip.

Add an idempotent reconcileTripSkeletons(tripId) that re-mirrors the trip's
day-assigned places onto every linked journey (add missing skeletons, refresh
date/time/location on move, remove skeletons for unassigned places; filled entries
are detached + noted, never destroyed). Call it from every REST assignment handler
and MCP assignment tool, and fire onPlaceDeleted on single MCP delete_place for
parity. Extract a shared insertSkeletonEntry helper.

* fix(memories): drop hidden Immich assets so Live Photo motion parts don't show a broken thumbnail (#1474)

* fix(transit): anchor arrive-by search time to the destination timezone (#1479)

* feat(plugins): host-brokered OAuth client + trustworthy inbound webhooks

Two integration primitives where the host owns the sensitive part.

Trustworthy webhooks:
- auth:false routes now receive req.headers, but ONLY an explicit, credential-free
  allowlist (the common provider signature/event headers — stripe-signature,
  x-hub-signature-256, svix-*, x-gitlab-event, …). Cookie/Authorization/X-Socket-Id
  and every session/forwarded-auth header are stripped; authenticated routes get {}.
  A plugin can finally verify a provider signature without any way to leak a session.

Host-brokered outbound OAuth (oauth:client):
- the HOST runs the whole flow — authorize -> callback -> token exchange -> refresh —
  with PKCE + single-use, user-bound, TTL'd state, and HOLDS the tokens. The client
  secret + refresh token never leave the host; the plugin only triggers connect and
  reads a short-lived access token via ctx.oauth.getAccessToken() for the acting user.
- provider config (authorize/token url + scopes + client id/secret) is the plugin's
  admin-owned instance settings; endpoints must be https (SSRF backstop, private/local
  hosts refused). Tokens per-user + encrypted at rest (apiKeyCrypto).
- GET/POST /api/plugin-oauth/:id/{status,connect,callback,disconnect} — JwtAuth-gated,
  the callback always redirects to an in-app /settings path (never leaks an error).
- Settings -> Plugins gains a Connect/Disconnect control per configured plugin.

migration: plugin_oauth_tokens + plugin_oauth_state. Perm labels + form strings
(22 locales), both wikis, service (PKCE/state/exchange/refresh/encrypt) + controller
+ proxy header-allowlist + rpc-host gating + create-rpc-host wiring tests.

* fix(navbar): re-measure sliding tab pill after font load and resize (#1481)

The active tab pill was measured once in a layout effect keyed only on activeTab, so on a hard reload it captured the active (bold) label's width against fallback-font metrics and never re-ran when the web font swapped in, leaving the pill slightly offset.

Re-measure after document.fonts.ready resolves and on ResizeObserver changes (container + active button), with an idempotent state update to avoid redundant renders.

* fix(collections): keep the Add-place button reachable after the first save

On a wide/desktop layout the collection toolbar (which hosts the Add
button) was gated on !mapOverlay, so it unmounted as soon as the list
gained its first place with coordinates — leaving only an easy-to-miss
"+" in the map overlay. Keep the toolbar rendered whenever the user can
add a place, and drop the now-redundant map-overlay Add button so there
is a single, predictable Add affordance in every state.

Fixes #1485

* feat(plugins): days + accommodations reads/writes, endpoints on the reservation write path

Community feedback on the 3.2.1 plugin surface: a plugin could write days but
never list them (no way to learn day ids), day_accommodations had no surface at
all, and trips.getReservations was the one reservation read that dropped the
endpoints/day_positions hydration.

- trips.getDays / trips.getAccommodations under db:read:trips (tripRead gate),
  wired to the same dayService lists the REST GETs use
- trips.getReservations now returns the hydrated REST-parity list (endpoints,
  day_positions, joins, normalized accommodation_id) - strict superset
- new db:write:accommodations scope: ctx.accommodations create/update/delete
  gated by day_edit like the accommodations REST path, with the partner-hotel
  reservation + delete cascade and broadcasts intact
- reservation create/update pin the endpoints shape up front (BadParams instead
  of a mid-transaction NOT-NULL or a silently dropped row)
- perm label in all 22 locales, consent PERM_KEYS, wiki tables

* feat(plugins): day-detail widget slot in the day panel

Widgets can now mount inside the trip planner's day panel
(capabilities.widget.slot: 'day-detail'), scoped to the open day via a dayId in
trek:context - the same pattern as the place-detail slot. Covers the requested
per-day plugin content (logistics, outfit planning, live flight status) without
a new plugin type. Day-detail widgets stay off the dashboard, the consent panel
labels the slot in all 22 locales.

* feat(plugins): let the frame CSP serve a plugin's own static assets

The sandboxed frame runs at an opaque origin, so script-src 'self' never
matched and a plugin's own <script src>/<link> files were blocked - authors had
to inline entire React builds into index.html. Add a scheme-less host-source
pinned to the plugin's own /plugin-frame/<id>/ path (charset-checked Host +
plugin id so a stray token can't widen the policy; malformed Host falls back to
inline-only). Multi-file client builds now load as-is; remote hosts stay
blocked, so script URLs remain useless as an egress channel.

* fix(plugin-sdk): catch the package up to the server capability surface

The npm SDK's validator still knew only the 3.2.1 permission set, so
'trek-plugin-sdk validate' (and pack/publish, which run it) hard-rejected any
manifest using the newer scopes - db:write:reservations, notify:send,
hook:map-marker-provider and 25 more. Sync KNOWN_PERMISSIONS with the server
envelope (48 entries), mirror the full PluginContext (reservations,
accommodations, notify/ai/oauth/settings, packing writes + bags, file writes,
collab, tags/todos/daynotes/collections/atlas/vacay/journal, weather,
categories), type the tableContributor/mapMarkerProvider hooks + the
entity/entityId event hint, accept the day-detail widget slot, and extend
createMockHost so plugin unit tests can exercise all of it.

* feat(plugins): grant-scoped entity snapshots on core events

An events:subscribe handler so far learned only WHICH entity changed - useful
for cache busting, useless for reacting to content, and the userless handler
can't refetch. Now the broadcast tap derives a whitelisted field snapshot of
the changed entity and the supervisor attaches it per plugin, only where the
granted set holds the family's matching db:read:* permission (trips family ->
db:read:trips, budget -> db:read:costs, packing -> db:read:packing, dayNote ->
db:read:daynotes, file -> db:read:files). No acting user is ever synthesized.

The whitelists are explicit per family, so user ids (owner/paid_by/uploaded_by/
participants/members), trips.feed_token and future migration columns never
travel; a private packing item (#858) yields no snapshot at all because its
core broadcast is owner-scoped; deletes/reorders/bulk ops carry none.

* feat(plugins): pdf-section, atlas-layer and journal-entry provider hooks

Three more declarative provider surfaces in the map-marker mould - plugins
return data specs, the host normalizes, caps and renders; a slow or failing
provider contributes nothing:

- hook:pdf-section-provider: sections (title + paragraphs + a simple table)
  appended to the trip PDF export, escaped into the same HTML/print pipeline
  as the core content
- hook:atlas-layer-provider: per-user country tint layers on the Atlas map
  (ISO 3166-1 alpha-2 codes only, tone-whitelisted, non-interactive pane so
  mark/unmark clicks keep working)
- hook:journal-entry-provider: extra rows on a journal entry card, gated by
  the same journey access check as the journal routes + the Journey addon

Permission labels in all 22 locales, consent PERM_KEYS, SDK types + manifest
validator in both SDK copies, wiki tables, per-controller hardening tests.

* feat(plugins): trip-page plugins can replace core planner tabs and pick their spot

A trip-page plugin that takes over a core surface (a transit planner
superseding Transports, a costs plugin superseding the budget tab) had to sit
awkwardly next to the tab it replaces. capabilities.tripPage now names the
core tabs to hide while the plugin is active - whitelisted (transports,
buchungen, listen, finanzplan, dateien, collab), 'plan' deliberately not
replaceable, and the tabs return the moment the plugin is deactivated - plus
an optional 0-based position for the plugin's own tab. The feed re-validates
the values out of the DB blob so a hand-edited row can't hide anything else,
the admin list chips a replacing plugin (all 22 locales), and a saved session
tab that got replaced falls back to the plan view.

Also fixes the plugins feed dropping the day-detail widget slot to 'sidebar',
which would have mounted a day-panel widget on the dashboard.

* fix(plugins): audit follow-ups — normalization, secret cleanup, cron leak, slot filter

Adversarial audit of the whole plugin PR surfaced 12 confirmed issues; this
addresses them:

- place-details provider was the ONE hook controller with no normalization: a
  plugin's href/label/value went to the client raw and unbounded. Now normalized
  like journal-entry-rows (safeUrl http/https/mailto, length + count caps).
- trip-warnings capped message length + per-provider count (was unbounded).
- uninstall(deleteData) now also purges plugin_user_config, plugin_oauth_tokens,
  plugin_oauth_state, plugin_meta_migrations and the capability audit — encrypted
  per-user API keys + OAuth refresh tokens no longer survive a 'delete all data'
  and get silently re-adopted on a same-id reinstall.
- supervisor: a crash-restart cycle leaked the dead child's node-cron tasks and
  re-scheduled fresh ones, so a job fired N+1 times per tick after N crashes.
  onExit now stops them, mirroring kill().
- dashboard sidebar no longer mounts place-detail/day-detail widgets (they belong
  in the planner panels).
- reservation endpoint validation relaxed to match the 3.2.1 service: a coord-less
  endpoint is accepted and dropped downstream instead of BadParams (no breaking
  change), while a bad role/non-string still rejects up front.
- a replaced core tab reached by programmatic nav now falls back to the plan view.
- trips.update caps title/description like the places path; plugin-db guard bans
  load_extension as defense-in-depth.
- wiki: event snapshots, string-typed context ids, dayId in the payload, the live
  provider hooks and the costs update/delete grant are now documented correctly.

* feat(plugins): phase-0 lifecycle hardening + per-plugin RPC rate limit

Operational-readiness fixes from the completeness audit:

- Re-activation after a failure worked again: a plugin left in 'error' state by
  a load-failure or crash-auto-disable stayed in the running map, so the admin's
  'enable' button was a silent no-op. activate() now replaces a dead entry.
- Per-plugin RPC rate limit at the dispatch boundary: every ctx.* call runs
  synchronously on the host thread, so a plugin in a tight loop could freeze the
  whole instance (and the reap sweep). A token bucket (generous burst) + an
  in-flight cap now throttle a runaway plugin with a retryable HOST_ERROR; a
  legitimate plugin never notices.
- plugin_error_log retention (500 rows/plugin) so a crash-looper can't grow
  trek.db without bound; the crash-timestamp array is trimmed to its window too.
- TREK_PLUGIN_PERMISSIONS=off now logs a loud one-time warning that the OS
  permission jail is disabled.

* feat(plugins): read symmetry + broker — collab/journal/atlas reads, file content, trip create, rates

The plugin API leaned write-heavy: collab and journal could be written but not
read, files listed but not read, and there was no way to create a trip or see
exchange rates. This closes those gaps in the established RPC+gate pattern (zero
architecture risk), and it's what unlocks the importer + finance plugin classes:

- collab reads: ctx.collab.listNotes/listPolls/listMessages under a new
  db:read:collab (membership + Collab addon, like the REST GETs)
- ctx.journal.getEntries(journeyId): a journey's entries, journey-access-checked,
  under the existing db:read:journal
- ctx.atlas.bucketList(): the acting user's bucket list, under db:read:atlas
- ctx.files.getContent(tripId, fileId): a file's bytes as base64 under a NEW
  db:read:files:content grant (reading a passport scan is more sensitive than its
  filename), size-capped at 10MB before it crosses the IPC pipe, trashed files
  refused
- ctx.trips.create(input): a new trip owned by the acting user, gated by the app's
  trip_create right + a bound user — the capability importers need
- ctx.rates.get(base): cached currency exchange rates, tenant-free like weather

Also caps trips.update title/description like the places path, and the plugin-db
guard now bans load_extension (defense-in-depth). SDK, mock-host, i18n (22
locales), consent labels and the wikis are all in lockstep.

* feat(plugins): deeper integration + user-facing activity transparency

Wave 2 of the completeness work — richer extension points, deeper metadata, and
the transparency that makes the broad read grants accountable:

- db:meta now attaches to reservations + accommodations too (not just
  trip/place/day), gated by reservation_edit / day_edit respectively — the
  natural home for an external-id mapping (AirTrail/calendar/booking-import sync)
  without forking the core schema.
- reservation-detail widget slot: a widget can mount on a booking card, scoped to
  the open reservation via reservationId in trek:context (the place-detail /
  day-detail pattern, third instance).
- tableContributor gains the transports + todos views, so a plugin can add
  host-rendered columns/actions there too.
- User activity log: GET /api/plugin-activity + a Settings → Plugins panel showing
  every host-mediated action a plugin took bound to the signed-in user, across all
  plugins, newest first — the user-facing half of the hash-chained audit. This is
  what legitimizes the deliberately broad read grants: not just the admin, the
  person whose data is read can see what was done in their name.
- DX: the local dev server now binds a default acting user, so the canonical
  ctx.trips.getPlaces(tripId) call works locally instead of failing RESOURCE_
  FORBIDDEN; the create scaffold drops the dead manifest routes[] / capabilities.nav
  fields the host ignores.

SDK, i18n (22 locales), consent labels and the wikis are all in lockstep.

* fix(memories): load Immich album photos on Immich v3

Immich v3 removed the `assets` property from AlbumResponseDto, so
`GET /api/albums/:id` no longer carries album contents. TREK read album
photos from that property, which now parses as undefined and degrades to
an empty array — hence "No photos yet" in the Journey gallery picker even
though the album header shows the right count (that count comes from
`GET /api/albums` -> assetCount, which v3 still returns).

Two call sites read the removed property. Besides getAlbumPhotos (the
reported bug), syncAlbumAssets failed silently on v3: it reported
`success: true, added: 0` while syncing nothing.

Fetch album contents via an `albumIds`-filtered `POST /api/search/metadata`
when `assets` is absent, and feature-detect rather than probe a version.
The two paths are not interchangeable: on v2, searchMetadata
unconditionally scopes results to `[self, ...partners]`
(`asset.ownerId = ANY(userIds)`), so an albumIds search against an album
shared by a non-partner returns nothing. v3 added an albumIds branch that
checks AlbumRead and skips that owner filter. v2 also hard-defaults
`visibility` to `timeline`, dropping archived assets. So v2 must keep
reading the album detail body, which this preserves exactly.

`withExif: true` is required on the search path: it has no default and
gates an inner join, so without it Immich omits `exifInfo` entirely and
every photo's city/country goes null.

The existing test mock returned an album detail body *with* `assets` — it
encoded the v2 assumption, which is why this shipped green. It now models
v3 by default, with explicit v2 coverage asserting no search call is made.

Fixes #1492

* feat(plugins): daily AI/notify budgets, runtime scheduler & reliable event redelivery

Per-plugin daily caps on ai.complete/ai.extract and notify.send (defaults
200 / 100, overridable via TREK_PLUGIN_AI_PER_DAY / TREK_PLUGIN_NOTIFY_PER_DAY),
seeded from the capability audit so a mid-day restart resumes the count instead
of resetting it. Surfaced at GET /plugins/:id/budget.

ctx.scheduler (at / in / every / cancel): persistent, userless timers that
survive restarts and fire a scheduled() handler, riding the existing jobs:run
grant so no new consent or admin setup is needed. Backed by
plugin_scheduled_tasks, swept every 30s, capped at 100 tasks/plugin with an 8 KB
payload and a 60s recurring floor; rows are removed on uninstall.

Core events that fire while a subscriber is mid-restart are now held in a
bounded in-memory buffer (200/plugin, 15 min TTL) and replayed once it goes
active again, with the events:subscribe grant and snapshot gating re-evaluated
at replay time so nothing leaks if a grant was revoked while the plugin was down.

* feat(plugins): GDPR data-subject rights — durable per-plugin erasure + export

New hook:user-data grant with two userless lifecycle handlers a plugin can put
on its definition: deleteUserData and exportUserData. Neither carries an acting
user — the plugin only learns the userId and touches its own db — so the grant
reads nothing from core data; it exists purely so a plugin can honour a GDPR
erasure or data-access request.

When a TREK account is deleted (admin or self-service), every installed plugin
holding the grant gets a row in a new durable erasure queue and its
deleteUserData runs on the next sweep, retried until it ACKs — so erasure
survives the plugin being offline or the server restarting. The core deletion
path notifies the runtime through a dependency-free relay (like the event sink),
keeping the auth/admin services decoupled from the plugins layer, and a plugin
bookkeeping error can never fail the account deletion.

Portability is served by GET /api/admin/plugins/user-data/:userId/export, which
fans exportUserData out to the active granted plugins and aggregates what each
holds about the user. Queue rows are purged on uninstall; the grant is labelled
in all 22 locales.

* feat(plugins): atomic ctx.db.tx for consistent multi-write on a plugin's own db

Plugins could already query/exec/migrate their own SQLite file, but a multi-step
write (move an item between tables, decrement one row and increment another) had
no way to be atomic. db.tx([{sql, args?}, …]) runs up to 100 statements in a
single transaction — all commit or all roll back — and reads within the batch see
its own earlier writes, so read-modify-write is safe. Each op is one statement:
a read returns { rows }, a write { changes }. The same guard (no ATTACH/PRAGMA/
RECURSIVE, size + row caps) applies to every statement in the batch.

* fix(memories): filter hidden Immich assets at the source, not just the picker

#1474 has the same root cause as #1492: the Immich v3 migration. On v2,
searchAssetBuilder hard-defaulted metadata search to `timeline` visibility
(`visibility = options.visibility ?? Timeline`), so hidden Live Photo
motion parts could never come back from a search. v3 defaults to any
visibility except `locked`, so they do — which is why the reporter is on
Immich 3.0.1 and why the bug never appeared before.

Ask for `visibility: 'timeline'` explicitly on the search path. That
restores v2 semantics on both versions and stops hidden assets crossing
the wire, which also fixes a pagination wart: a full page half-made of
motion parts previously rendered as a half-empty page, because hasMore
counts the raw page length while the filter shrinks the rendered set.

The client-side filter was display-only, applied in searchPhotos and
getAlbumPhotos — both picker-listing paths. Nothing guarded persistence
or rendering: getOrCreateTrekPhoto stores any id it is handed, pipeAsset
forwards Immich's 400/404 verbatim, and the photo grid is a plain <img>
with no onError. So syncAlbumAssets, which filtered `type === 'IMAGE'`
only, could persist a hidden IMAGE as a permanently broken tile. It now
applies the same guard, extracted as isVisibleAsset().

Albums keep their filter rather than requesting `timeline` visibility:
albums legitimately contain archived assets, and both the v2 album body
and the v3 album search return them.

Does not address tiles already persisted before this — those still render
broken and need a separate fix.

Refs #1474

* docs(memories): correct Immich version boundaries in the hidden-asset comments

Verified against the v1.120.0 → v3.0.0 OpenAPI specs and server source. The
previous comments said "Immich v2 hard-defaulted metadata search to timeline
visibility". That is true only for 1.133–1.144.

- `visibility` was added in 1.133.0. Before that, searchAssetBuilder applied
  `.$if(options.isVisible !== undefined, ...)` with no default, so pre-1.133
  servers returned hidden assets too. #1474 was therefore not purely a v3
  regression.
- Those servers strip the `visibility: 'timeline'` filter rather than
  rejecting it: Immich validates with `whitelist: true` and no
  `forbidNonWhitelisted`. So the request stays valid, the filter is a no-op,
  and isVisibleAsset() is the ONLY guard there. Say so, so it does not get
  removed later as redundant.
- `albumIds` only exists from 1.135.0. Because unknown properties are stripped,
  an albumIds search against an older server would silently drop the album
  filter and return the entire library as the album's contents. Feature
  detection on `assets` (present through 1.144.1, absent on v3) makes that
  unreachable; a version probe with a wrong boundary would not.

Also cite Immich's own enum, which documents AssetVisibility.Hidden as
"Video part of the LivePhotos and MotionPhotos".

Comments only — no behavior change.

* feat(plugins): dashboard trip-card badges + a mock-host driver for plugin tests

Two additions that round out the plugin platform's breadth and its authoring DX.

tripCardProvider hook (hook:trip-card-provider): a plugin returns small declarative
badges for the dashboard trip cards. The dashboard fetches all visible cards in one
call; the host access-checks every tripId for the acting user, bounds each field
(label/value length, enum tone, http/https/mailto-only url), caps the count and drops
any badge for a card that wasn't requested — plugin JS never runs on the dashboard.
Rendered as text chips under the card meta; labelled + gated in all 22 locales.

createMockHost now exposes run(def) — the other half of a plugin unit test. Where the
ctx recorders capture what a plugin read, run() fires its own entry points (route, job,
scheduled, event, plugin-event, deleteUserData, exportUserData, provider hooks) against
the same mock ctx, and host.scheduled surfaces the timers it armed. A handler the plugin
didn't declare throws a clear error instead of a silent no-op.

* feat(plugins): include plugin data + code in backups, applied on restart

A TREK backup archived travel.db + uploads + the encryption key, but each plugin's
own SQLite file — the ONLY copy of the user data it holds — and its installed code
lived in separate trees that were never captured, so a restore left the plugins rows
with no data or code behind them.

createBackup now adds plugins-data/ (each plugin's db + WAL sidecars, so SQLite
recovers a consistent snapshot) and plugins-code/ (skipping dev-links by realpath, so
an author's linked source is never bundled). Restore can't swap those live — the
runtime holds each plugin db open — so it STAGES the extracted trees beside the live
ones and the runtime swaps them in at the next boot, before it opens anything. Same
"applies on restart" model the bundled encryption key already uses: no plugin quiesce,
no swap under open handles, no new admin setup. Older archives without the trees restore
exactly as before.

* fix(plugins): audit — runtime robustness, security & data-lifecycle fixes

Fixes from an adversarial audit of the plugin system, host/runtime side:

Robustness:
- getPluginDataDb recreated a handle a terminal-failure dispose had closed but
  left cached, so a re-enabled plugin's db:own threw on every call — recreate
  when the cached handle is shut.
- ctx.ws.broadcast* now carry _inv, so the host can bind the acting user (the
  capability was silently refused, i.e. dead, without it).
- ctx.events.emit swallows a rejected emit instead of crashing the child into a
  terminal 'error'; an uncaught throw AFTER activation is treated as a crash
  (restart with backoff), not a load failure.
- A crash-respawned child gets the same activation deadline as a first activation
  and the buffered-event queue is cleared on the timeout path, so a hung onLoad
  after a crash can't peg a core and orphan events forever.
- Expired buffered events are pruned by the reaper, not only at flush; the
  scheduler + erasure sweeps scope their LIMIT window to ACTIVE plugins so a
  backlog for inactive plugins can't starve deliverable work.

Security / integrity:
- Unix-domain-socket / named-pipe connects are refused by default in the egress
  guard (a host-local pivot to docker.sock / DB sockets), under the same policy
  as private IPs.
- db.tx refuses transaction-control statements (a raw COMMIT would break its
  atomicity) and caps rows across the WHOLE batch, not per statement.
- plugin_capability_audit is retention-capped per plugin (chain-safe: retained
  rows stay self-verifying), so it can't grow unbounded in the shared db.
- A cap of 0 in TREK_PLUGIN_AI_PER_DAY / _NOTIFY_PER_DAY now disables the broker
  instead of falling back to the default.

GDPR data lifecycle:
- Account deletion now erases host-side per-user plugin tables (config, OAuth
  tokens/state) and enqueues the own-db erasure from the CORE path, so it works
  even when the runtime is disabled or pre-boot; guest deletion does the same.
- uninstall keeps a pending erasure when data is retained (deleteData=false);
  erasure delivery is no longer grant-re-checked (a queued erasure is a duty);
  export flags installed-but-inactive plugins as pending instead of omitting them.

Backup/restore:
- Plugin DBs are WAL-checkpointed before archiving (no torn/stale snapshots).
- Restore applies the staged trees immediately by quiescing the plugins (no
  unbounded gap where a later unrelated restart would revert diverged data);
  the swap is content-level (safe on a volume-mounted root) and preserves
  dev-links; the decompressed-size cap is operator-raisable.

* fix(plugins): audit — hook-output hardening, dashboard slot & mock-host parity

- Map-marker and atlas-layer tones were validated on String(tone) but emitted
  raw, so a non-string tone (an object with a matching toString) slipped through
  and crashed the client that renders it — check the raw value against the enum.
- View-contribution column/action caps are now PER ENTITY, not per view, so a
  plugin's columns no longer vanish from every table row past the first 20; the
  dashboard trip-card badge cap is per card (≥ one on every visible card).
- A reservation-detail widget no longer also renders as a context-free dashboard
  sidebar card (the inline filter was missing that slot).
- mock-host matches the real host: it ignores asUserId on trip reads (bind the
  acting user), throws on a wrong user-scope notify target instead of coercing,
  enforces the scheduler caps, and detects RETURNING as a read in db.tx — so a
  passing author test can't hide a production RESOURCE_FORBIDDEN.

* feat(plugins): full ctx parity in the dev server + fire jobs/events/hooks locally

The trek-plugin dev server injected only ~6 of the ~35 ctx areas, so any plugin
touching ctx.costs/packing/files/notify/ai/settings/scheduler/meta/oauth/db.tx/…
hit a TypeError in local dev while the same code passed mock-host tests and worked
installed. It also could only exercise routes.

Delegate every non-db-own capability to a grant-enforcing mock host (the same one
unit tests use) while keeping the real node:sqlite for db:own and dev-native ws
capture + logging — so the whole surface works in dev with the exact production
permission rules. dev-fixtures.json now takes the createMockHost options shape, so
you can seed the full surface. New GET /__dev/fire/<kind>[/<name>][/<fn>] fires a
job, scheduled timer, event subscription, GDPR handler or provider hook against the
dev ctx, closing the "can't test non-routes locally" gap.

* feat(plugins): wire the photoProvider + calendarSource hooks to real core consumers

Both hooks were declared, typed and documented but NO core code ever invoked them,
so an author could build, mock-test and install a photo or calendar plugin that
silently did nothing. Give each a real consumer that fans out to it, exactly like
the other eight provider hooks:

- GET /api/plugin-photos/search (+ /sources, /item) aggregates photoProvider results
  for the picker — {id, title?, thumbnailUrl, fullUrl, takenAt?}, thumbnail/full URLs
  http/https-only (they become <img src>), per-source count capped, failing source
  skipped.
- GET /api/plugin-calendar?start=&end= aggregates calendarSource events for the
  signed-in user — {id, title, start, end, allDay} ISO, count capped, failing source
  skipped, sensible default window.

Both run with the acting user bound. The SDK interfaces now pass ctx as the last arg
(so a source can reach ctx.settings/oauth/http for its backend), and the wiki marks
them live instead of "reserved — no core consumer".

* feat(plugins): close the create-heavy API asymmetries importers/sync hit

Core services implemented these but plugins had no path to them, so the flagship
importer/sync integrations hit real walls. Added, each reusing the EXISTING grant
(no new consent):

- ctx.trips.removeMember(tripId, userId) — reconcile DEPARTURES, not just additions
  (db:write:members + member_manage). Never removes the owner (that would orphan the
  trip); ownership transfer stays a separate deliberate action.
- ctx.journal.createJourney({title, subtitle?, trip_ids?}) / deleteJourney(journeyId)
  — an importer can now bootstrap the journal it fills with entries and clean it up
  (db:write:journal), instead of only appending to journals a human created first.

Wired end-to-end (envelope → rpc-host → create-rpc-host reusing tripService/
journeyService → both SDK copies → mock-host) and documented. (trips.delete needs its
own destructive permission + consent copy and collab edit/delete + collections.delete
remain — tracked as small follow-ups.)

* feat(plugins): strip emojis from plugin-rendered text so it matches TREK's lucide UI

Plugin authors (especially AI-generated ones) sprinkle emojis into the declarative
text TREK renders in its OWN chrome — hook contributions (badges, columns, warnings,
PDF sections, map-marker/atlas labels, journal rows, place details, trip-card badges,
calendar + photo titles) and notifications — which clashes with TREK's lucide-only icon
language.

A shared stripEmoji() removes emojis (incl. flag/ZWJ/variation-selector sequences) and
tidies the leftover whitespace, applied at the render boundary in every hook-contribution
normalizer and in notify.send — so no matter what a plugin returns, the text TREK draws
stays emoji-free. It does NOT touch a plugin's own sandboxed /ui frame (the author's to
design), and it leaves photo ids verbatim (they round-trip to getById). The validate CLI
warns when a manifest name/description contains emojis, nudging authors to the declarative
`icon` field (a lucide name) instead.

* fix(plugins): harden the restore-apply path — regressions from the backup/dev fix pass

A final audit of the fix pass caught three regressions clustered in the two newest
surfaces; the restore path could both crash the server and destroy data.

- CRITICAL: a restore quiesces plugins via supervisor.shutdownAll() AFTER closeDb(), but
  shutdownAll killed children without first marking them stopped, so each child 'exit'
  took the CRASH path and wrote crash-accounting rows into the now-closed core DB — the
  throw escaped an EventEmitter listener as an uncaughtException and killed the whole
  process mid-restore. shutdownAll now marks every entry stopped and drops it from
  `running` BEFORE the kills (so onExit early-returns), and the onStatus/onLog DB hooks
  are wrapped in try/catch (also covers the stderr→onLog path). This also stops a normal
  shutdown from logging phantom "crashed" rows.
- HIGH: swapContents cleared live entries then MOVED staged ones in, so a crash mid-move
  permanently deleted a plugin's only data copy (staging was already emptied, so a retry
  couldn't restore it). It now COPIES each staged entry over the live one and only deletes
  staging at the very end — `staged` stays the complete source of truth, making the whole
  operation crash-idempotent.
- HIGH: the dev server lost the actingUserId=1 default in the mock-host refactor, so a
  fresh scaffold refused every user-bound capability. Restored.

* fix(plugins): final-audit medium/low findings

- GDPR export flags an active plugin whose export errored/timed out as `pending`
  instead of silently omitting it (collectUserExport now returns a discriminated
  result), so a data-access export never reads complete while missing data.
- Account deletion also enqueues an erasure for plugins UNINSTALLED with retained
  data (an orphan data dir) — a same-id reinstall now honours the deletion instead
  of re-adopting the user's data forever.
- oauth.getToken returns null in a userless context (matching the SDK/mock contract)
  instead of throwing RESOURCE_FORBIDDEN a background caller can't handle.
- Crash-backoff restart is identity-guarded (+ the timer is tracked and cleared like
  the activation timer), so a disable + re-enable during the backoff window can no
  longer respawn a ghost child from the replaced entry.
- db.tx transaction-control guard strips leading comments first, so `/* */COMMIT`
  can't slip past the start-anchored check and break batch atomicity.
- createJournal inherits its cover only from a trip that was actually LINKED
  (access-checked), closing a cross-tenant cover-image read on plugin + REST paths.
- trip-warnings drops a null array element instead of losing ALL of that provider's
  warnings; plugin-activity floors a non-integer ?limit so it can't 500.
- The trek-plugin dev server binds loopback only and refuses cross-site requests to
  its side-effectful /__dev/fire endpoints (it serves real routes + no-auth dev
  actions).

* fix(plugins): clear no-misleading-character-class in the emoji stripper

The character class listed the ZWJ, variation selectors and combining keycap
marks as members, which eslint reads as an accidental combined grapheme and
rejected on CI. Pull the emoji glyphs out into Extended_Pictographic /
Regional_Indicator alternatives so only the joiner/selector code points stay in
the class, with a scoped disable where the rule still can't tell them apart.
While here, reset lastIndex before the /g regex is reused in hasEmoji() so a
second call can't resume mid-string and miss a leading emoji.

* fix(security): trip-scope note-file deletion and guard the LLM base URL

Two reported issues:

- deleteNoteFile only matched on the note id and file id, so a member of trip A
  could delete a file attached to a note in trip B by guessing its id. Thread the
  trip id through the service and controller and scope the delete to it, the way
  every other collab operation already does.

- The LLM extraction clients fetched the user-configured base URL directly, so a
  user could point it at the cloud-metadata endpoint (169.254.169.254) and read
  the echoed error body. Route both clients through a new safeFetchLlm() that
  blocks the link-local/metadata range while still allowing a local or LAN Ollama
  (loopback and private ranges stay reachable), pinned to the resolved IP so a
  hostname can't rebind to the metadata address after the check.

* fix(security): route every LLM client through the SSRF guard

The base-URL SSRF fix covered the openai-compatible and anthropic clients but
missed the native Ollama /api/chat client and the /api/tags + /api/pull model-
management calls, whic…

* fix(plugins): repair plain-HTTP egress and forward the private-egress opt-out

Two pre-existing bugs in the plugin egress guard, found by running a plugin
against a real service end to end.

1. Every plain-HTTP request a plugin made was refused, whatever host it had
   declared. Node pre-normalises `net.connect()` args into an [options, cb]
   array and passes THAT array as the single argument; undici's plain-HTTP
   connector takes this path, its TLS connector does not. classifyConnect read
   `host` off the array, got undefined, and fell back to 'localhost' — so a
   fetch to a declared, public host was rejected with the nonsense message
   "localhost is not in the plugin's declared hosts". It failed closed, so it
   was never a security hole, and it went unnoticed because the only shipped
   egress plugin uses HTTPS. unwrapConnectArgs() unwraps the normalised form
   before anything reads host/path.

2. TREK_PLUGIN_ALLOW_PRIVATE_EGRESS could never have any effect. The guard that
   reads it runs INSIDE the child, whose env is scrubbed to a four-entry
   whitelist that never included it — so a documented setting (wiki/
   Environment-Variables.md) was wired to nothing, and no plugin could reach a
   self-hoster's LAN service no matter what the operator set. Forwarded only
   when set, so the default stays the secure block-private policy.

Regression tests cover the normalised form in both directions: the real host is
now resolved, and an undeclared host, a private IP and a unix socket are all
still refused when passed that way.

* feat(notifications): let a plugin register a notification channel

TREK's four channels (in-app, email, webhook, ntfy) were a closed set:
notificationService.send() dispatched with four copy-pasted `if` blocks and no
provider abstraction, so a fifth channel meant editing eight files by hand. A
plugin could produce a notification via ctx.notify.send(), but never deliver
one.

A plugin now registers a channel with `hooks.notificationChannel` +
`hook:notification-channel` on a plain `type: 'integration'` — not a new manifest
type, so the TREK-Plugins registry schema and both its CI gates are untouched.

Core refactor
- New channel registry (services/notifications/): email/webhook/ntfy become
  ExternalChannel providers wrapping the EXISTING send functions — no delivery
  logic is rewritten, only relocated. In-app deliberately stays out: it writes
  typed rows with scope/target/callbacks, not a rendered title+body, the same
  line shared/ already draws with i18n/externalNotifications.
- The event text is now rendered once per recipient instead of once per channel.
- The channel set is open: NotifChannel becomes a string, the matrix is
  registry-derived, and the UI columns are server-driven. The DB column was
  already bare TEXT and the Zod contract already a string record — only the
  TypeScript and the two UIs were ever closed.

The hook runs USERLESS. Every other hook is user-initiated, so actingUserId falls
out of the request; a notification is host-initiated for an ARBITRARY recipient,
so ctx.settings.get() would return undefined. The host resolves the recipient's
decrypted scope:'user' settings itself and passes them as an argument. That is
what lets a channel plugin be handed someone's push token WITHOUT being handed
the right to read their trips as them.

Enabling the plugin is the opt-in: a plugin channel is not gated on the admin's
`notification_channels` list. A built-in always exists in code and needs an
explicit switch; a plugin channel only exists because an admin enabled that
plugin. (Nothing could write a `plugin:` id into that CSV anyway, and the admin
toggle rebuilt it from three booleans, silently dropping anything else — so
requiring a second opt-in meant the channel could never be turned on at all.)

Also fixed, found while building this:
- Plugin settings keys were unvalidated, so a field named `__proto__` or
  `constructor` resolved off Object.prototype: a REQUIRED field with such a name
  reported as configured for every user who had configured nothing — enough, for
  a channel, to be dispatched to everyone with no credentials. Keys are now
  constrained at install and the config blob is parsed null-prototype, so it is
  impossible even for an already-installed plugin.
- A `select` field's options were cast straight through, so the obvious
  `["1","5"]` form rendered every dropdown entry BLANK (the client reads
  value/label). Now coerced, and malformed options are rejected.

Also adds: operator-supplied egress hosts (a plugin talking to a self-hosted
service can't name the operator's host at publish time, so an admin adds it
post-install and the runtime re-spawns the child with the widened allow-list —
only for a plugin that DECLARED operatorEgress, and only an admin, never a user);
settings-page actions (a "Test connection" button, user-initiated so
ctx.settings.get() returns the clicking user's own value); and a Gotify-shaped
notification-channel template in the SDK.

Verified end to end against a real Gotify container, not just in tests.

* docs(wiki): document the plugin notification-channel surface

Covers the pieces added in the previous commits, in the pages a reader would
actually reach for:

- Plugins.md (the admin-facing page) had none of it: notification channels,
  settings actions, and a full "Allowed hosts" section — including what
  operator-supplied egress deliberately does NOT let anyone do.
- Plugin-Development.md: the notificationChannel hook (and why it is the one hook
  with no acting user), settings-page actions, operatorEgress, and the manifest
  reference rows.
- Plugin-Cookbook.md: a "become a notification channel" recipe and a
  "Test connection button" recipe.
- Plugin-Permissions.md: hook:notification-channel, operatorEgress under the
  outbound section, and settings actions under "not a permission".
- Notifications.md: plugin channels alongside the four built-ins.

* fix(sdk): allow empty egress if and only if operatorEgress is true

* ci: don't run repo-specific workflows on forks

Guard release, publish, wiki-deploy and issue/PR-triage workflows with a
`github.repository` check so they no-op in forks instead of failing or
acting on the fork's own issues, PRs, tags and registries.

Also skip the Docker Scout scan for pull requests from forks: Docker Hub
secrets are never exposed there, so the login step could not succeed.

Tests and lint stay ungated — they need no secrets and are the gate for
incoming fork PRs.

* feat(sdk): add missing methods in mock-host

* fix(airports): rebuild the json file

* fix(airports.json): add small airports too

* fix(public transit): only show public transit option when a trip has actual dates

* fix(plugins): reap a queued erasure only once the plugin's data is gone

The orphan reap deleted every queue row whose plugin had left the registry, but
uninstall(deleteData=false) removes the plugins row while deliberately keeping the
data dir AND the queued erasure so a same-id reinstall can still honour it. The reap
now deletes a row only when the plugin's data dir is actually gone; a deleteData=true
uninstall already clears the rows itself.

* fix(backup): snapshot the core DB and swap restores atomically

createBackup archived travel.db via the archiver's lazy live-file read, so a WAL
auto-checkpoint firing mid-stream could write a torn database into the zip. It now
VACUUM INTOs a point-in-time snapshot and archives that, the same guarantee plugin
DBs already get. restoreFromZip swapped the DB by unlink-then-copy, which on an
interrupted restore could leave no valid travel.db; it now copies to a temp file and
renames it into place (atomic), dropping the stale -wal/-shm sidecars first.

* fix(deploy): Recreate strategy for the SQLite volume, pin the root compose image

The Helm Deployment had no strategy, so the default RollingUpdate would start a second
pod holding the same ReadWriteOnce PVC before the old one exits — a Multi-Attach
deadlock or two writers on one SQLite file. Default to Recreate (overridable for
ReadWriteMany). The root docker-compose.yml pinned trek:dev, a tag no workflow builds,
so a clone-and-up at the release tag ran a stale image; pin it to :latest like the README.

* fix(security): re-validate LLM endpoint fetch redirects per hop (GHSA-fmq9)

safeFetchLlm left undici's default redirect:'follow', so a configured LLM
endpoint could 302 to http://169.254.169.254/ and reach cloud-metadata
credentials — the DNS pin does not cover an IP-literal redirect hop, since
net.connect skips the pinned lookup for a literal IP. Follow redirects
manually now, re-resolving/re-checking/re-pinning each hop (allowing LAN/
localhost as before). Also block the Alibaba metadata IPs directly.

* fix(plugins): throttle the plugin log channel to prevent host-thread DoS

The per-plugin RpcRateLimiter only guarded the ctx.* (req) channel; ctx.log.*,
stdout/stderr and unknown evt topics reached a synchronous INSERT+prune on the
host thread unthrottled, so a while(true) ctx.log.error(...) loop could freeze
the instance. Route every plugin-driven log path through a per-plugin log token
bucket; excess lines are dropped with a summary line on resume.

* fix(plugin-sdk): serve dev /ui frame at /ui/index.html so relative assets resolve (#1526)

The dev server embedded the plugin UI as <iframe src="/ui"> (no trailing
slash), so a multi-file build's relative asset URLs (./assets/x.js from Vite
base:'./') resolved against the origin root -> /assets/x.js -> 404, even though
the files are served at /ui/assets/*. The real host loads the frame at
/plugin-frame/<id>/index.html where the same relative URLs resolve correctly, so
dev now matches it by loading /ui/index.html (and also serves /ui/ as index.html).

* i18n: improve Russian translations (#1539)

* v3.4.0 (#1527)

* fix(plugins): unknown column

* fix(mcp): reuse MCP sessions instead of creating one per tool call

The /mcp CORS layer never set exposedHeaders, so Access-Control-Expose-Headers
was absent and browser-context MCP clients (Claude Desktop connectors,
Claude.ai, MCP Inspector) could not read Mcp-Session-Id off the initialize
response. Unable to echo it back, every request looked like a fresh initialize:
one McpServer and one session per tool call, until the per-user cap returned a
429 and the integration died until the container was restarted.

The idle sweep was not at fault — it expires on lastActivity with a 1h default,
so sessions born seconds apart are nowhere near expiry, hence 'cleaned 0'.

- expose Mcp-Session-Id, MCP-Protocol-Version and WWW-Authenticate
- evict a user's least-recently-active session at the cap rather than
  refusing the request, so a client that cannot persist its session id (or a
  proxy that strips the header) can never wedge the server
- close the McpServer/transport orphaned by every session-less non-initialize
  POST, which was leaked: never mapped, never swept, never closed
- return the cap error as JSON-RPC so clients surface the real reason
- warn on session-less POSTs to make a header-stripping proxy diagnosable

* chore(deps): declare @modelcontextprotocol/sdk ^1.29.0

Matches the version already resolved in the lockfile; no dependency-tree change.

* docs(mcp): add the reverse proxy specs for MCP

* fix(plugins): stop the row ⋯ menu being clipped by the sidebar

The menu was an in-flow `absolute` div, and its ancestor (PageSidebar) is
`overflow-hidden` — which clips absolutely-positioned descendants regardless
of z-index. With enough plugins installed a row sits low enough that its menu
runs past the sidebar's bottom edge and gets chopped, taking Delete with it,
so the plugin could no longer be uninstalled from the UI.

Portal the menu to <body> and position it `fixed` against the ⋯ button,
flipping upward when the bottom is tight and re-anchoring on scroll/resize.
A fixed child of <body> has no overflow ancestor, so nothing can clip it.

Closes #1523

* feat(plugins): surface author-signature status and add a scoped re-trust override

TREK has always verified an author's Ed25519 signature and TOFU-pinned the key on
first install, but none of it was ever shown: a successfully-installed UNSIGNED
plugin looked identical to a signed one, and a signature-refused update left the
plugin quietly pinned at its old version with the reason dying in a toast.

Give the four refusal conditions machine-readable codes (SIGNATURE_MISSING /
_INCOMPLETE / _KEY_CHANGED / _INVALID), persist a refusal on the plugin row so the
admin list keeps showing it, and badge Signed/Unsigned in the list and in Discover.

Only SIGNATURE_KEY_CHANGED is overridable — an author can legitimately rotate a key;
a signature that does not verify means the bytes are not what the author signed, and
there is no story where waving that through is right. The override re-pins and
updates in ONE call (POST :id/retrust): a re-pin that waited for a follow-up /update
would leave the plugin pinned to a key no install had ever been verified against if
that second call never came. The artifact must still verify under the new key, so a
re-trust moves the pin from one verified key to another.

assertRetrustable re-derives the condition server-side, so the UI hiding the button
is a convenience, not the control, and it echoes back the full key the admin was
shown so a re-key since the dialog rendered is refused. The rotation is written to
the admin audit log with both fingerprints — after an incident, "which key did we
move from, and to what?" is the question a single key cannot answer.

* fix(plugins): let a plugin's frame reach the hosts an admin added for it

The frame's connect-src was built from the manifest's http:outbound grants alone, but
the child's egress guard is the UNION of those and the hosts an admin added after
install for an operatorEgress plugin (a self-hosted Gotify, an ntfy — hosts the author
cannot know in advance). So such a plugin WITH A UI could call the operator's host from
its server and was CSP-blocked in its own iframe.

Match the frame to the child. The admin consented to these hosts at install and the
child already reaches them, so this widens no trust boundary that isn't already crossed.
Both sources stay validated on the way in, and the interpolation filter is unchanged.

* fix(plugins): report a rejected events.emit on the plugin's own log stream

The host can reject an emit (an undeclared event name, a rate limit). The rejection must
not escape — a detached rejection crashes the child and terminally disables the plugin
over one bad emit — but swallowing it silently left an author with no way to discover
that `emits` was missing from their manifest. Surface it as a warning instead.

* feat(plugin-sdk): expose the raw request body for webhook signature checks

A webhook author must run their HMAC over the exact bytes the sender signed. `body` is
the PARSED value, and re-serializing it will not reproduce those bytes — key order,
whitespace and unicode escaping all differ — so the signature never matches. Document
`rawBodyBase64`, which the host already sets on auth:false routes.

* fix(plugin-sdk): refuse to overwrite a released artifact, and keep the packed zip

A released artifact is IMMUTABLE: the registry pins its sha256, so overwriting the bytes
of a release already in the registry breaks the checksum for everyone who installed that
version — they can no longer install or update it. The old code blanket-caught every
`gh release create` failure (auth, network, a bad repo) and turned it into a --clobber
upload. Probe for the release explicitly and refuse unless --force.

Also stop deleting plugin.zip on the way out. It is the exact bytes the release and the
entry's sha256 pin were computed from; a re-pack on another machine or SDK version can
differ (CRLF, walk order), so anyone re-running `entry`/`sign` afterwards must hash THAT
file, not a rebuild.

* fix(plugin-sdk): don't fail submit when the fork already has an upstream remote

`gh repo clone` of a fork may already have wired `upstream`, in which case a bare
`remote add` exits non-zero and took the whole submit down. Set it either way.

* fix(plugin-sdk): make the dev server behave like the host

Three ways `dev` lied to an author about how their plugin would run in production:

- ctx.db: one try/catch wrapped both the node:sqlite probe AND opening the database, so
  an mkdir/permission failure silently degraded to the in-memory stub — which swallows
  every write while reporting success. A db:own plugin "worked" in dev and persisted
  nothing. Probe separately: fail loudly on a real error, degrade only on old Node, and
  say plainly that the stub discards writes.
- notificationChannel: the host fires it with no acting user and hands the recipient's
  decrypted settings in as a separate `config` argument — send(msg, config, ctx) /
  test(config, ctx). Firing it like an ordinary hook passed `ctx` where `config` belongs,
  so a channel plugin read its settings off ctx and was broken in production.
- /preview pinned tripId 42 while the scaffold seeds trip 1, so the widget's first
  trek:invoke hit assertMember(42) and 500'd. Preview against a trip that exists.

* fix(plugin-sdk): reject unknown flags, wire up create's new flags, and add --help

`parse()` accepts any --x, so a flag a command does not read was silently dropped:
`create --template notification-channel` cheerfully scaffolded a blank plugin. Silently
ignoring an author's explicit instruction is worse than refusing it, so unknown flags are
now an error, and create actually forwards --template/--egress/--required-addons.

A bare --permissions used to split the string "true" into a permission literally named
`true`; listFlag() now treats a valueless flag as absent.

Since an unknown flag is now fatal, `--help` has to exist: it is intercepted before the
flag check and prints usage on stdout with exit 0.

* chore(plugins): drop three unused eslint-disable directives

The no-console rule is not enabled for these files, so the directives were dead and
eslint reports them as unused.

* fix(plugin-sdk): bring preflight back in step with the registry's gates

preflight exists to tell an author what TREK-Plugins' CI will say before they open the
PR. The registry now verifies author signatures, and preflight didn't — so it drifted
into the one failure mode it must never have: a false green. An author trusts a green.

- Verify the signature against the artifact bytes. preflight already downloads them for
  the sha256 check, so this costs one call. Without it, signing with the wrong key (or
  re-packing after signing) sails through and is caught at review.
- Check the signature SHAPE (checkSignatureShape): a key with no signed version, a
  signature with no key, a malformed key or signature. TREK refuses to install a
  half-signed entry, so such an entry is dead on arrival.
- Default apiVersion to 1 before comparing. It is OPTIONAL in the manifest — install/
  manifest.ts and `entry` both default it — so a manifest that legally omits it was
  failing preflight with "manifest apiVersion undefined != entry 1" while the registry
  passed it. A false RED, which teaches authors to ignore the tool.
- Check requiredAddons/pluginDependencies parity, and operatorEgress without an
  http:outbound grant.

The verifier is a port of the host's install/verify-signature.ts (the registry has its
own port); sign.ts's verifyArtifact only understands the bare key/signature pair the SDK
itself emits and cannot judge a minisign key. A test pins all three to the same verdicts.

* feat(plugins): enforce compatibility range

* chore: bump sdk version

* test(e2e): repair the trip-creation specs

create-trip and trip-planner have been failing for a while — long enough for
three separate UI changes to drift past them, which nothing caught because CI
runs vitest only and never invokes Playwright.

Each failure was masking the next:

- The release-notice modal greets a freshly seeded user and its backdrop
  swallowed the click on .add-trip-card. Added a shared dismissSystemNotices()
  helper (the X only shows on the notice's last page, so it has to page through
  first).
- .modal-backdrop no longer exists — the class was namespaced to
  .trek-modal-backdrop so content blockers stop hiding it.
- input[type=text].first() is no longer the Title field: the cover-image search
  inputs now sit above it, so the specs were typing the trip name into the photo
  search box and creating an untitled trip that never matched getByText(title).

* fix(planner): gate drag & drop on pointer type, not viewport width (#1432)

The 3.2.1 fix disabled drag on "mobile", but nothing in the client has ever
detected touch — "mobile" was inferred from viewport width, at four independent
breakpoints. A tablet is a coarse-pointer device at a *desktop* width, so an
iPad (820-1366px) fell on the wrong side of all four, which is why iPhone was
fixed and iPad was not:

- useTripPlanner's isMobile (<768px) is what disarmed `draggable`, so on iPad
  rows stayed draggable and a scroll swipe became an HTML5 drag.
- TripPlannerPage hardcoded isMobile={false} on the desktop PlacesSidebar, so
  its drop handlers and the drop-to-import overlay could never be disabled —
  that overlay is the reported symptom.
- The arrow-button reorder fallback was revealed only below 767px, leaving iPad
  with no drag *and* no fallback.
- touchDragPolyfill loaded drag-drop-touch at >=1024px, synthesising drags from
  touchmove — on a landscape iPad that re-armed the very gesture hijack 3.2.1
  removed.

Adds useIsTouch() ((pointer: coarse)) as a signal separate from isMobile: layout
stays width-driven, so the iPad keeps the desktop two-pane planner, while every
drag affordance is gated on isMobile || isTouch. The reorder arrows now show on
coarse pointers, and the polyfill loads only on hybrid laptops
((pointer: fine) and (any-pointer: coarse)), which also removes the #1440
phantom-dblclick map zoom on tablets.

Guarded by unit tests plus an e2e spec on real WebKit in an iPad Pro 11 context
— the engine matters, since every browser on iPadOS is WebKit underneath, which
is why the reporter hit this in all three they tried.

* test(planner): guard the day-plan reorder arrows against phantom clicks

The hover rule that reveals the reorder arrows had been dead since the
TypeScript migration: it targeted `.place-row:hover .reorder-btns`, and neither
class exists — the component renders `.reorder-buttons` inside an unclassed row.
The buttons sat at opacity:0 on desktop with nothing to reveal them.

That was not merely invisible. opacity:0 still hit-tests, so every itinerary row
and day note carried an invisible, fully clickable target that silently
reordered the trip:

  opacity: "0", visibility: "visible", pointerEvents: "auto"
  elementFromPoint(centre) -> BUTTON, inside .reorder-buttons

The repair — pointer-events: none while hidden, plus a working
.dp-row:hover/:focus-within reveal (focus-within so the buttons are also
reachable by keyboard, which the file's JS-hover pattern cannot do) — lives in
index.css and DayPlanSidebar.tsx. Both files also carry the #1432 drag gating,
so they went with that commit rather than being split mid-file; this commit is
the regression guard for them.

E2E rather than unit, because jsdom does not evaluate :hover. Against the old
CSS it fails on exactly the right assertion: "hidden arrows must not swallow
clicks" — expected false, received true.

* fix(pdf): keep the header gap on day-header overflow pages (#1531)

The repeated <thead> day header (#1471) carried no gap before the first
card on overflow pages: the 12px sat in .day-body's block-start padding,
which a fragmented box only paints on its first fragment. Move it to the
thead cell so it repeats with the header; the day's first page renders
pixel-identically.

* fix(budget): keep "no one paid yet" when editing an expense (#1533)

The ExpenseModal payer initializer fell back to the current user whenever the
edited item had no payer, so reopening an expense saved with "no one paid yet"
silently reselected "You" — and re-saving then recorded the current user as the
payer, corrupting the balances. An absent payer on an existing expense is a
deliberate value, so only a brand-new expense defaults to me.

* feat(sdk): support for plugin icons

* fix(planner): keep the places filter applied and visible across tab switches

The category and all/unplanned/tracks filters lived twice: a local copy in
the sidebar driving the checkboxes and the list, and a page-level copy
driving the map markers, synced only when a checkbox was clicked. Switching
planner tabs unmounts the sidebar, so the local copy reset to "All
Categories" while the markers stayed filtered — and the only way out was
toggling any category on and off again (#1541).

The filter now lives once in the trip store, next to selectedDayId: it
survives the Plan tab unmounting (and the mobile places sheet closing),
keeps both sidebar instances in agreement, and resets when another trip
loads.

* feat(airtrail): import connecting flights as one multi-leg booking

AirTrail flights always imported as separate single-leg reservations, so a
layover could not be expressed and the connection country ended up counted
as visited in Atlas (#1535).

The import picker now detects connection chains among the listed flights —
each leg departing from the airport the previous one landed at, onward
within 24 hours, and never back to the origin (an out-and-back is a return,
not a connection) — and offers to import each chain as one flight with
layover stops, on by default. The joined booking keeps per-leg airline,
flight number, times and seat in metadata.legs, files every leg on its own
trip day, and mirrors the first/last leg flat, exactly like the manual
multi-leg form. With the connection stored as a stop endpoint, the existing
Atlas role filter excludes the layover country on its own.

AirTrail has no multi-leg flight a joined booking could round-trip to, so
it imports detached from live sync, with every source flight id recorded in
metadata.airtrail_ids — the picker and the server-side dedupe both treat
those legs as imported, per leg, even across trip members. The server
re-validates each requested chain and falls back to individual imports when
it does not actually connect.

* fix(airtrail): stop syncing a booking once it grows extra stops

A linked flight that becomes multi-leg locally no longer matches the single
AirTrail flight it was imported from: pushing would rewrite that flight to
span the whole route, and the next pull would flatten the layover chain
back to a plain from/to. Both sync directions now detach the link instead —
the same state a joined import starts in, surfaced by the existing "Not
synced" badge.

TransportModal also carries metadata.airtrail_ids through re-saves (like it
already does for transit itineraries and day positions), so editing a
joined booking cannot cost it its import dedupe and get its legs re-offered
in the picker.

* fix(planner): default a new accommodation to checking out the next day

The hotel picker pre-filled "Apply to days" with the same day for check-in
and check-out — a stay that ends the day it begins. New accommodations now
default to the following day for check-out; the last trip day keeps the
same-day range, and editing still seeds from the stored range.

* docs(wiki): document the AirTrail import and connection joining

* fix(plugins): fold resolvePluginIcon into PluginIcon

pluginIcon.ts and PluginIcon.tsx resolve to the same file on the
case-insensitive filesystems dev checkouts commonly sit on (Windows,
macOS) — tsc sees both casings of one module and fails with TS1149 in
every importer. Keep the resolver and the component in one module.

* feat(sdk): add update verification

* chore: remove test files

* fix(sdk) rework the sdk helpers and DX/UX

* fix(sdk) harden dev environment

* fix(budget): add back the multi payer selection

* fix(map): stop MapLibre mouse rotation from reversing near mid-screen

Since MapLibre 5's camera rewrite, the right-button rotate handler flips
its sign whenever the cursor sits above a mid-screen line it derives by
re-projecting the map center. That line drifts with the bearing by a
fraction of a pixel, so inside the 100px band around the screen center a
steady horizontal drag lands alternately above and below it — every
processed movement reverses the previous one and the camera ping-pongs in
place instead of rotating (#1545). A real hand crossing the line mid-drag
flips the rotation direction outright. maplibre-gl 4.x rotated from plain
horizontal movement and had none of this.

Passing aroundCenter: false opts the handler out of the around-center
mode and restores the 4.x/mapbox-gl behaviour: horizontal drag rotates,
vertical drag pitches, in one continuous motion. Applied to all three GL
map builds (planner, journey, settings preview); mapbox-gl keeps its
options untouched.

* fix(budget): settle in the trip's real currency, not always EUR

The settlement route read `currency` off the row returned by canAccessTrip,
whose SELECT never included the column. A cast hid the mistake, so trip.currency
was always undefined and the settlement was told every trip is in EUR.

Balances are netted in the trip currency and converted to the display currency
once. With the trip mislabelled EUR, expenses in the trip's own currency still
cancelled out, but an expense booked in a foreign currency was divided by its
frozen rate into trip-currency units, then converted again as if those were
euros — inflating balances by the EUR/trip rate (~27x for a RUB trip with a USD
expense, #1543). MCP was unaffected: it reads the currency with its own SELECT.

Select the currency in canAccessTrip so the read is real. The settlement maths
was correct all along; it was simply being lied to about the base currency.

Fixes #1543

* feat(trips): let users set the trip currency, and rebase the budget when it changes

The trip currency is the base every expense and settle-up is netted against, but
the only picker for it lived in the legacy Budget addon panel — so on the Costs
panel a trip was stuck with whatever it was created as. Add the field to the trip
form, on create and on edit, gated on trip_edit. The REST and MCP write paths
already accepted `currency`; only the form was missing.

Changing it is not a rename, though: an expense's frozen `exchange_rate` is
"units of its currency per 1 trip currency", and `currency = NULL` means "the
trip's own", so both are relative to the outgoing base. Swapping it out from
under them redenominates the implicit rows (9 000 RUB becoming 9 000 EUR) and
leaves the frozen rates pointing at a currency the trip no longer uses — the same
mismatch that inflated #1543.

So rebaseTripCurrency() runs first, while the old currency is still on the row:
it pins the implicit rows to the outgoing currency and re-freezes every rate
against the incoming one, for expenses and settle-up transfers alike. No stored
amount is rewritten — each keeps the figure the user typed, in the currency they
typed it in, and its real-world value survives the switch.

Also covers the #1543 data as a settlement regression test.

* feat(budget): give settle-up payments their own currency

A transfer settling a shared bill can be made in any currency — paying a rouble
debt in euros is normal — and the server has stored `currency` + a frozen
`exchange_rate` on every transfer since #1445, re-freezing it on edit. The UI
just never let anyone choose one, so a payment silently inherited whatever the
viewer's display currency happened to be.

Add the picker to the payment modal, mirroring the expense modal, and reopen an
existing payment in the currency it was actually recorded in. The ledger row now
shows a foreign payment as `$30.00 -> 27,00 EUR` like a foreign expense does,
instead of stamping the display currency's symbol onto the raw stored number.

The Settle buttons on the suggested flows keep sending the display currency:
those amounts are computed in it.

* feat(settings): make the display currency optional, falling back to the trip's

Costs already resolved `default_currency || trip.currency || 'EUR'`, but the
setting could never actually be empty: the store seeded 'USD', so a user who had
never touched it silently had every trip converted into dollars, and the picker
offered no way to unset it.

Seed it empty and lead the picker with a "Trip currency" option, so an unset
preference means "show each trip in its own currency" instead of forcing them all
through one code. An explicit empty value persists and beats the admin-set
instance default — it is a deliberate choice, not an absence.

This also brings the public share's fallback to life: the share payload has
resolved sharer's currency -> trip currency since #1361, but the trip-currency
branch was unreachable while every owner had a currency forced on them.

Plugins are handed `formats.currency` as a concrete code, so PluginFrame now
resolves the same chain rather than passing an empty string through the bridge.

* docs(wiki): explain the three currencies and how they relate

Trip currency, expense currency and display currency answer three different
questions, and nothing said so: the trip currency wasn't documented at all (it
had no picker until now), and Budget-Tracking conflated the other two while
still claiming 47 currencies and a display currency that always came from
Settings.

Add a Currencies page as the one place they're defined together — the trip
currency as the accounting base, the expense currency as the receipt with its
rate frozen at entry, the display currency as presentation-only — plus what
happens when a trip's currency changes, which currency a public share renders
in, and what belongs to the Costs addon versus the trip itself.

Rewrite Budget-Tracking's currency section against it, document the currency
field in Creating-a-Trip and the display currency in Display-Settings (which
never mentioned it), and note the sharer-or-trip fallback in Public-Share-Links.

* feat(help): serve the in-app wiki from disk instead of fetching GitHub

The /help pages fetched their markdown from raw.githubusercontent.com at
runtime, so a self-hosted install was served docs from main rather than the
version it was actually running, and help was unusable without network access.
The wiki/ directory was in the repo the whole time; the bundled-snapshot
fallback the code reached for was gitignored and never populated by any build
step, so it was dead code.

Read wiki/ straight from disk instead. server/{src,dist}/services both sit
three levels under the repo root, so a single __dirname anchor resolves in dev,
a built source install, vitest and Docker with no copy or build step. GitHub is
kept strictly as a fallback for when the directory cannot be resolved, decided
once at load by probing for _Sidebar.md: a page missing from a present wiki is
a genuine 404, since falling back per-file would reintroduce the version skew
this removes.

Ship wiki/ in the image: .dockerignore excluded it outright, so the COPY alone
would have produced an image with no wiki, and the GitHub fallback would have
masked that at runtime. Add a real path-containment check on assets now that
the path becomes a filesystem read rather than a URL, and document
TREK_WIKI_DIR as an off-by-default escape hatch across the deployment configs.

* fix(plugins): serve frame assets root-relative and cross-origin loadable

res.sendFile(absolutePath) resolves against the rewritten req.url under
the Nest ExpressAdapter and 404s spuriously (files-download already
works around the same trap), which broke every plugin frame document.
And helmet's CORP: same-origin made the browser drop the opaque-origin
frame's own script/style subresources, so a multi-file plugin client
could never boot. Serve root-relative and mark frame responses
cross-origin — sandbox + per-plugin CSP stay the isolation boundary.

* feat(plugins): let a plugin ship its own settings page

A plugin that declares capabilities.settingsUi: true gets its
client/settings.html framed as a card under Settings -> Plugins — same
opaque-origin sandbox and postMessage bridge as its widget, sized via
trek:resize. Hosts that predate the flag strip it at install, so old
instances keep working and simply don't show the card.

* feat(map): open maps framed on their places (builds on #1393) (#1556)

* fix(map): fit MapLibre routes reliably

* fix(map): default planner map to world view

* fix(map): only await route geometry when a route is actually pending

The fit armed a pending route-refit slot on every fitKey change, even with no
route drawn, and only ever cleared it when a route arrived. So a route toggled
on much later — after the user had panned elsewhere — was mistaken for the
awaited geometry and yanked the camera back, and only on the first toggle.

Arm the slot only when a route is already on screen: updateRouteForDay lays down
straight lines in the same batch as the fit and upgrades them to real geometry a
moment later, so an empty route at fit time means none is coming.

* fix(collections): open the empty collection map on the world view

A collection with no mappable places centred on Paris, the same hardcoded
default this branch removes everywhere else.

* feat(map): open the map framed on its places

A trip in Japan opened on the world view at 0,0 and only then animated a fitBounds
flight across the planet — the hardcoded default was the map's answer to a question
its own places already answer.

Each renderer now derives its opening camera from the places it receives, at
construction: MapView for Leaflet, MapViewGL for MapLibre and Mapbox (whose zoom
runs one level below Leaflet's, measuring against a 512px world tile rather than
256px). Doing it at construction is what confines it to load — the map is built
once, and by then the trip's places are in hand. Nothing recomputes it afterwards,
so the camera stays where the user leaves it, and the opening fit stands down
rather than overruling the gentler zoom a lone place opens at.

A trip with no coordinates still falls back to the world view. Collections and the
public shared-trip page frame themselves the same way.

* refactor(settings): drop the default map centre and zoom

Nothing reads them now that every map frames itself on its own places, and a
home-city default was the wrong answer for the next trip on the other side of the
world. The style preview keeps a fixed location of its own: it needs a city to show
label density and 3D buildings, which open ocean cannot.

* fix(map): frame the map the way each renderer can actually draw

Two defects the unit tests missed and running the app exposed.

MapLibre and Mapbox opened on Null Island at zoom 2 regardless of the places: the
effect that mirrors an external centre prop onto the camera also ran on mount, so
it jumped straight to the default nobody passed and threw away the camera the map
had just been built with. It now only responds to actual changes, which is what it
was for. Leaflet was unaffected — its controller already guarded on the centre
changing.

A trip spanning Sydney, Reykjavik and Santiago lost Sydney's marker entirely. The
narrowest arc containing all three crosses the antimeridian, and framing there is
only sound on a renderer that repeats the world: MapLibre and Mapbox draw a marker
on whichever copy is nearest the camera, Leaflet draws one world and puts the
marker at its absolute position — off-screen. Leaflet now spans the long way round,
as L.latLngBounds would. The test that should have caught this wrapped the x-offset
in its own projection helper, quietly assuming behaviour Leaflet does not have; it
now models each renderer's real wrapping.

---------

Co-authored-by: Azalea <noreply@aza.moe>

* feat(mcp): expose public transit planning tools (#1558)

* feat(mcp): add public transit planning tools

* refactor(transit): reuse local time conversion

* fix(mcp): harden transit journey validation

* refactor(transit): centralize itinerary processing

* fix(mcp): return the transit itineraries the provider actually offers

search_transit_routes validated each itinerary leg's mode against
SCHEDULED_TRANSIT_MODES, but that constant is the request-side filter
whitelist — the modes a caller may ask for — not the modes MOTIS can return.
Its default TRANSIT mode expands to TRAM,FERRY,AIRPLANE,BUS,COACH,RAIL,ODM,
RIDE_SHARING,FUNICULAR,AERIAL_LIFT,OTHER, and street legs can be BIKE/CAR/
RENTAL. Any itinerary carrying one of those failed the parse and was dropped
by the flatMap, so the tool reported fewer routes than exist — or none at all.
Against the live provider, Trondheim → Ålesund returns 5 itineraries and 3 of
them contain an AIRPLANE leg, so the tool silently discarded them; the web app
shows all 5, because it treats mode as a free string and renders anything
non-WALK as a transit leg.

Accept any mode token on a leg and keep the existing "at least one non-WALK
leg" rule as the real gate, which restores parity with the web app. Everything
downstream keeps its mode !== 'WALK' semantics, so a journey created over MCP
is identical to one created in the app.

Dropping an itinerary is still possible when provider data fails the remaining
consistency rules, but it is indistinguishable from "no routes exist" — so
search_transit_routes now reports a `dropped` count alongside the results.

---------

Co-authored-by: Uzini <43294422+Uziniii@users.noreply.github.com>

* fix: show map poi search controls on mobile (#1555)

* fix(pdf): use the trip's actual currency instead of hardcoded EUR (#1519)

* fix(pdf): use the trip's actual currency instead of hardcoded EUR

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(i18n): drop hardcoded currency text from pdf.costLabel across locales

Remove redundant EUR/currency references from pdf.costLabel translations
across 21 locales now that the PDF export correctly renders amounts
with their actual trip currency via formatMoney().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(pdf): drop hardcoded euro-shaped icon from place price chip

svgEuro rendered a fixed € glyph next to the price chip regardless of
the trip's actual currency, undermining the currency fix. Swap for a
currency-neutral coin icon.

---------

Co-authored-by: Nguyen Trong Binh <nguytb15@VN1N07HO1CD1015.local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* fix(trips): pin place prices when the trip currency changes

A place price with no currency of its own means "the trip's own currency" —
that is how the PDF export and the place chips read it since #1519. But
rebaseTripCurrency() only pinned budget_items and budget_settlements, so
switching a trip's currency silently redenominated every implicit place
price: a €15 museum on a trip moved to JPY started reading as ¥15. Same
class of mismatch as #1543, on the places surface.

Pin priced, currency-less places to the outgoing currency inside the same
transaction. No amount is rewritten — the figure the user typed keeps its
real-world value, it just stops being ambiguous about which unit it is in.
Bump updated_at as well: it doubles as the optimistic-concurrency token
(#1135), so a client holding the pre-switch row can no longer write the
pin away.

Document place prices in the wiki's currency model, and drop the now-stale
"in EUR" from the PDF export's estimated-cost stat.

* fix(budget): re-split expenses when a member is removed

* fix(plugins): derive plugin req.user.isAdmin from role, not is_admin

* chore(test): make jsdom Storage work under Node's native Web Storage globals

* fix: recompute vacay calendar when week start changes (#1554)

* feat(mobile): comprehensive mobile UI rewrite

Add a dedicated phone UI (<768px) under client/src/mobile/, mounted by
MobileShell while the desktop/tablet code paths stay untouched. Each page
delegates to an M-screen that reuses the existing hooks, stores and services.

Covers dashboard, trip planner (plan timeline, map, places, tabs and sheets),
vacay, atlas, journey, collections, settings, admin and notifications, with a
shared component kit (MSheet, MBottomNav, MGlassBar, MSegmented, MToggle, …)
and design tokens in mobile.css.

Empty states use an animated TREK mascot (MDancingTrek) with per-scene props.
Adds the mobile* i18n namespaces (mobileTrip/mobileJourney/mobileVacay/
mobileAtlas/mobileNav/mobileAdmin/mobileSettings/mobileCollections) across all
22 locales.

* fix(websocket): handle socket 'error' events to prevent crash on malformed frames (#1584)

* fix(packing): apply templates into the active list tab (#1581)

* Fix packing list readability on mobile

* Localize packing quantity label in overflow menu

* feat(mobile): accommodation & save-to-collection sheets, trip loading splash

Add a dedicated mobile accommodation sheet and a save-to-collection sheet,
plus an animated trip loading splash with stepped status copy. Make the vacay
day/company-holiday toggles optimistic so cells react instantly and roll back
on error. Wire up the "subscribe to all trips" calendar feed and cover-image
add on the new-trip sheet. Sync the new dashboard/trip i18n keys across all
22 locales.

* fix(atlas): stream boundary GeoJSON instead of caching parsed bundles (#1585)

* fix(atlas): stream boundary GeoJSON instead of caching parsed bundles

* fix(atlas): scan admin1 features in one pass to avoid O(n^2) build

createFeatureSplitter re-scanned each partial Feature from the start on
every gunzip chunk, so a large feature (Canada ~5.5MB, ~340 chunks) made
the one-time admin1 store build O(n^2). That pushed the streaming build
(~4.2s) past the 15s test timeout under CI coverage/fork contention and
added the same latency to the first live region request.

Carry scan state (position, brace depth, string state) across chunks so
each character is examined once. Output is byte-identical (3228 features,
197 countries) and peak build memory is unchanged (~192MB RSS under a hard
512MB cap); build time drops to ~1.7s.

* fix(i18n): add packing.quantity to all locales

The packing.quantity key existed only in en (Qty) and de (Menge), so
i18n:parity:strict and the client parity test failed for the other 20
locales. Add the key everywhere; en/de are unchanged.

---------

Co-authored-by: jubnl <jgunther021@gmail.com>

* fix(trips): keep accommodations on their dates when the trip range shifts (#1288)

The v3.1.3 fix re-anchored dated bookings after a trip date change but
explicitly excluded hotels: day_accommodations has no absolute date columns,
so stays remained glued to positionally re-dated day rows and shifted with
the range. updateTrip now snapshots day dates before generateDays and a new
resyncAccommodationDays re-anchors each stay (and its linked hotel
reservation, restamping its stale reservation_time) to the days holding its
pre-change dates; out-of-range stays stay glued so whole-trip moves still
shift together. Unlinked dated hotels resync like any booking, and the
date-change block is wrapped in a transaction.

Changing the start date now also asks how plans should follow via a new
date_shift_mode field ('keep_bookings' default / 'shift_all', which reuses
the reorder/insert restamp path to glue everything), exposed in the trip
edit modal (all 22 locales), the shared contract, and the MCP update_trip
tool. Clients no longer show stale state: the initiator reloads
reservations + accommodations after saving, collaborators refetch on a
date-changing trip:updated, and reconnect hydration nudges the planner's
accommodations too.

* fix(extract): retry with json_object and surface AI import failures (#1546)

OpenAI-compatible providers that only support json_object (DeepSeek,
Mistral, some vLLM/llama.cpp) reject the json_schema response_format
with a 400, and the resulting error was swallowed silently: not logged
server-side and never rendered by the background-task widget, leaving
only a generic "no reservations" message.

- Retry the chat/completions request once with response_format
  json_object when the json_schema attempt returns 400 (non-NuExtract
  only); the system prompt already dictates the output shape
- Log swallowed llm-parse errors with an [llm-parse] tag so failures
  show up in server logs
- Render task warnings under the empty-preview note in the background
  tasks widget so the actual provider error reaches the user

* fix(costs): keep ticket item amounts visible on narrow screens (#1568)

* chore: update repo url

* chore: update repo url

* chore: update repo url

* chore: update repo url

* fix(security): block IPv6 transition addresses (NAT64/6to4/Teredo) in SSRF guard

An attacker-controlled DNS record pointing at a NAT64 (64:ff9b::/96),
6to4 (2002::/16), or Teredo (2001:0000::/32) address that embeds a
private IPv4 (e.g. 64:ff9b::a9fe:a9fe = 169.254.169.254) bypassed the
SSRF guard: none of the guard functions recognised these ranges, so on a
host that routes the transition prefix the connection reached the
embedded private target (cloud metadata / internal SSRF).

Add a shared embeddedTransitionIpv4() detector and re-apply each guard's
own blocklist to the extracted IPv4 in isAlwaysBlocked, isPrivateNetwork,
isLinkLocal (ssrfGuard.ts) and isBlockedIp (egress-policy.ts). A
transition address to a public IPv4 stays allowed so legitimate
IPv6-only egress is unaffected.

egress-policy.ts keeps the detector inline to preserve its dependency-free
contract for the isolated plugin subprocess.

* chore: Add star history

* Revert "chore: Add star history"

This reverts commit f9d5f75837.

* fix(map): draw transit routes even without other places on the day

The reservation overlays hide any transport whose from/to endpoints
project closer than a per-type pixel threshold (200px for transit) to
declutter tiny no-op straight connectors. A transit journey, though,
draws its real rail/bus alignment rather than a straight endpoint line,
so on a zoomed-out day — one with no other places to tighten the map
onto — its stations fall under the threshold and the whole route
vanishes.

Exempt a transit booking that carries real per-leg geometry from the
proximity gate in both renderers (Leaflet + MapLibre); a geometry-less
transit keeps the straight-arc declutter.

* fix(atlas): resolve region AND country by coordinates against the bundled polygons

Rebased onto dev's streaming atlas index (#1576): region resolution now
resolves a place's lat/lng directly against the same bundled admin1
polygons the client renders — offline, deterministic, and guaranteed to
match a bundle feature — rather than trusting Nominatim's address level,
which can name a subdivision the bundle doesn't carry (Barcelona's ES-B
province vs the bundle's ES-CT autonomous community) and never highlight.
Country resolution moves to the same coordinates-first order, so a place
stored "..., San Francisco, CA" no longer resolves to Canada.

admin1 is held as per-country GeoJSON text (never parsed whole, #1576),
so a country's regions are flattened to the compact Float64Array form and
cached on first use — only visited countries pay the parse. The stale GB
constituent-country rescue is removed, and a one-time migration clears the
re-derivable place_regions cache so every place re-resolves under the new
logic.

Closes #1547

* fix(client): convert mixed-currency day/trip cost totals instead of mislabeling raw sums (#1561)

Day headers, the plan sidebar footer and the PDF day/cover totals summed
raw place prices across currencies and labeled the result with a single
currency — a $2,730.27 hotel on a NOK trip read as "2730 NOK".

Totals now convert every amount into a base currency via the existing
frankfurter rates (sidebar: the user's display currency, falling back to
the trip's; PDF: the trip currency, resolved once before rendering so the
document is consistent), marked with "≈" when a conversion happened. When
a rate is unavailable (offline, blocked egress, unknown code) they fall
back to an honest per-currency breakdown ("2 500 kr + $2,730.27") instead
of folding foreign amounts into a mislabeled number. All-same-currency
trips make no FX request, so offline PDF export keeps working.

Also swaps the place inspector's hardcoded € chip icon for a neutral one
and formats the price via formatMoney in the place's own currency.

Note: day-header totals move from "50 EUR" to Intl formatting ("50 €").

* feat(atlas): let a visited region be hidden, cascading to the country when none remain (server)

Countries already have a hide/tombstone mechanism (hidden_countries, #1490):
a zero-count derived country can be dismissed and stays gone across reloads.
Regions had no equivalent — unmarkRegionVisited only ever deleted a
manually-marked visited_regions row, a no-op for the common case of a region
derived fresh from place_regions on every request, so there was no way to
dismiss one at all (place-derived or otherwise).

Adds the region-level counterpart:
- New hidden_regions table (user_id, region_code, country_code), mirroring
  hidden_countries.
- getVisitedRegions() filters its result through it, the same way getStats()
  already filters through getHiddenCountries().
- unmarkRegionVisited() now tombstones unconditionally (not just for a
  manually-marked region — a region with a real place attached, e.g. one
  misassigned by a border-simplification gap, is exactly the case this
  exists for) and derives the country code from the region code's
  "<country>-<rest>" prefix when there's no visited_regions row to read it
  from.
- Cascade: after hiding a region, if the country has no other visible region
  left (checked against place_regions + visited_regions, minus hidden_regions),
  the country is hidden too via the existing unmarkCountryVisited.
- markRegionVisited() clears both tombstones on re-mark, so a region (and its
  cascade-hidden country) can come back.

Note the cascade only has a visible effect on a country with no real place
attached to it — getStats' places-derived country entries are never
suppressed by hidden_countries (#1490's deliberate "reappears with a real
place" rule), so hiding every region of a country that DOES have real places
leaves the country visible, by the same existing design. Test coverage
reflects this.

Server-side only — client wiring (a way to trigger this from the map) is a
separate commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(atlas): let a visited region be hidden, cascading to the country when none remain (client)

Client wiring for the server-side hide-region feature (previous commit).

Clicking a visited region on the map used to open the hide/unmark
confirmation only when it was manually marked (visitedRegions[...].manuallyMarked);
a region derived from real place data instead opened the country-detail
view, with no way to dismiss it at all. Now any visited region offers the
same "Remove this region from your visited list?" confirmation regardless of
how it was derived — country details remain reachable via the country
search/sidebar, which was never gated on this in the first place.

The confirm handler's optimistic country-removal check dropped its
`&& r.manuallyMarked` filter on the remaining-regions count, matching the
server's unconditional cascade — but keeps the existing "only when the
country has zero real places/trips" guard, since a country backed by real
data is never actually hidden server-side (#1490) and removing it from the
UI early would just flash and reappear on the next reload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(settings): make fresh-instance unit defaults internally consistent

A brand-new instance — no admin-set defaults, no saved value — rendered
temperatures in Fahrenheit and times on the 12-hour clock while distances
defaulted to metric, a mix that matches no locale. The store also seeded
'fahrenheit'/'12h' while DisplaySettingsTab's fallback said 'celsius', so the
two paths disagreed about the intended default in the first place.

Default to one system — celsius / metric / 24h — matching the already-metric
distance_unit. The unit defaults now live in a single exported DEFAULT_SETTINGS
that both the store and DisplaySettingsTab's fallback read, so they can't drift
apart again. Admin-set user defaults (getAdminUserDefaults) and any value a user
has already saved still take precedence; only the code-level default changed.

* feat(i18n): complete Catalan (ca) translation

Adds Catalan as a supported language — the full shared/src/i18n/ca locale
(all domain files plus the notification texts), registered in
SUPPORTED_LANGUAGES and the client locale loader. Rebuilt onto current dev so
the locale is at full key parity with en (i18n:parity:strict clean).

* fix(map): stop real road-route fetches from dying under StrictMode

useTransportRoutes cached its AbortController in a ref that was
created once and aborted on unmount. React StrictMode's dev-only
mount->cleanup->remount cycle ran that abort during the *simulated*
cleanup, permanently poisoning the controller before the real mount's
fetch ever started — every road-routed booking (car/bus/taxi/bicycle)
silently fell back to a straight line in local dev, while production
builds (no StrictMode double-invoke) worked fine.

Fix: create a fresh AbortController per effect run instead of a
ref-cached singleton, and synchronously un-mark a job as "attempted"
in that same run's cleanup if it didn't settle before the cleanup
fired — so a StrictMode remount (or any other pre-completion
cancellation) retries instead of being skipped forever. Verified
against the real OSRM endpoint in a running dev server: all four
road-routed legs on a live trip now resolve with real road geometry
instead of straight lines.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(map): add reservation route-visibility util

Extracts the route-visibility filter that was hand-copied, identically,
in both MapView.tsx and MapViewGL.tsx into one pure, unit-tested
function: a reservation's route shows on the map when it's a transit
booking with the day-route toggle on, or its id is in the caller's
visible-ids set. isRoutableReservation (>= 2 endpoints) is exported
separately since callers besides the map filter need the same check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(map): add per-trip connections-visibility resolution util

Pure storage/resolution logic for a trip's booking-route visibility
preference, keyed trek:visible-connections:<tripId>. Two modes:
'only' (nothing shown except the listed ids — today's existing
behavior; a legacy bare-array localStorage value parses as this mode
for backward compatibility) and 'all-except' (everything routable
shown except the listed ids). A trip with no stored preference falls
back to the account-wide default (all or nothing) without writing
anything, so flipping the account setting later never silently
overrides a trip with an explicit per-trip choice already recorded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(settings): add always-show-booking-routes account setting

New map_always_show_routes account setting, defaulting to off, i18n'd
across all 22 supported locales. Lives in Display > Travel & map,
directly under Booking route labels — its closest sibling — using
that section's immediate-save On/Off pattern rather than a separate
toggle+Save flow, since it's a booking-display preference, not a map
render-config option.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(map): per-trip booking-route visibility with account default and bulk toggle

Lets a user see a booking's route on the map without manually
toggling it per item, two ways:

- The account setting from the previous commit sets the default for
  any trip that's never had its routes touched before.
- A new bulk "show all / hide all" button in the day-plan toolbar
  flips a trip explicitly between the two connectionsVisibility modes,
  independent of the per-item toggle (which still edits whichever
  mode's id list is active, in both directions, including while the
  account default is on).

useTripPlanner.ts resolves a trip's effective visible-connection ids
from connectionsVisibility.ts + the account setting + the trip's
routable reservations, and exposes it through the same
visibleConnections/toggleConnection contract MapView, MapViewGL and
DayPlanSidebar already had, plus allConnectionsShown/
toggleAllConnections for the new bulk control. MapView/MapViewGL
consume it via the shared reservationRoutes util instead of each
carrying their own copy of the filter.

The bulk toggle's tooltip gets its own map.showAllConnections/
hideAllConnections i18n keys (all 22 locales) distinct from the
per-item toggle's text, and matches the per-item toggle's active
(solid blue) styling rather than the toolbar's generic hover tint.

Manually verified end-to-end in a running dev server: the account
default seeding an untouched trip, the bulk toggle flipping a trip
between all-shown/all-hidden, and a single per-leg override while the
trip is in all-shown mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Update documentation for booking visibility change

* fix(i18n): add Catalan keys for the booking-route toggle + fix the language-count test

#1483 added map.showAllConnections/hideAllConnections and
settings.alwaysShowRoutes/Hint to every locale that existed when it was
written; Catalan (#1418) landed just after and was missing them, which
broke i18n parity. Also bumps the SUPPORTED_LANGUAGES test to 23 entries
and asserts the Catalan one — the ca addition left that assertion stale.

* test(e2e): add a reproducible documentation screenshot run

The wiki screenshots had drifted three months and two releases behind the UI:
39 of 46 assets came from a single commit in April, and pages such as
Budget-Tracking carry a disclaimer that their own images are out of date.
Retaking them by hand is what created that drift in the first place.

This adds a `screenshots` Playwright project that captures them from a dev
build instead:

- seed.ts populates a demo trip over the REST API — deliberately in JPY, so the
  3.4.0 currency work (per-trip currency, frozen FX rates, foreign-currency
  settle-up) is actually visible rather than hidden behind EUR.
- shot.ts settles the page before capturing (fonts, images, transitions) so
  captures don't catch skeleton loaders, and rewrites /auth/app-config to clear
  dev_mode — the E2E backend runs NODE_ENV=development, which would otherwise
  put a "Dev: Notifications" tab no real deployment has into every admin shot.
- promote.mjs downscales 2880px captures to 1600px on the way into
  wiki/assets/, which is 70% smaller for detail the wiki never renders.

Tabs are located by their visible label, so a rename like Budget → Costs fails
the run loudly instead of quietly capturing the wrong panel.

npm run shots && npm run shots:promote

* docs(wiki): retake screenshots against 3.4.0

Regenerated with `npm run shots` from the dev build. Replaces 21 assets and
adds 13 surfaces that had no screenshot at all.

Notable corrections:

- Collections.md referenced assets/Collections.png, which was never committed —
  the only broken image in the wiki. It now exists.
- The Budget panel became Costs in 3.3.0 (#1464); every budget image still
  showed the old label.
- The trip-create dialog gained a Currency field in 3.4.0, absent from the old
  TripCreate.png.
- Settings grew a Plugins tab and the admin sidebar a Plugins entry; neither
  appeared in the old sidebar shots.
- Weather now renders in Celsius. A fresh instance defaults to Fahrenheit while
  distance defaults to metric, so the seed pins both units — the mismatched
  defaults are a separate bug, not fixed here.

Koffi is installed from the community registry rather than dev-linked, because
dev-link and sideload both stamp a badge on the plugin card that an ordinary
install never shows.

Total 3.6 MB for 34 images, against 26 MB for the 46 assets already in the
directory.

No wiki page text is touched here — pages still point at the old filenames
where those were kept, and the newly added images are not referenced yet.

* fix(help): make wiki links and anchors work in the in-app reader

The wiki pages are written in GitHub-wiki style, and 455 of their links across
81 pages use the bare relative form — `[Currencies](Currencies)` — against only
114 `[[..]]` links. processMarkdown only rewrote the latter, so in-app every one
of those 455 fell through to HelpPage's external-link branch, opened a new tab
and 404'd. Since 6c87bf2f serves the wiki from disk, that is the primary way
users read these docs.

Rewriting them in the renderer fixes every page at once and keeps the sources
GitHub-compatible, so contributors can keep writing either form and neither
target breaks. Rewriting the 81 files instead would have fixed today's pages and
left the trap open for tomorrow's.

Also:

- Code is protected from the rewrite. Plugin-Development.md documents
  `actions[key](ctx)`, which reads as a markdown link and would otherwise be
  corrupted into `actions[key](/help/ctx)` inside a verbatim snippet.
- `[[Page#anchor|Slug]]` no longer renders its anchor as visible link text.
- Headings carry GitHub-compatible ids, so the 22 in-page `](#anchor)` links
  scroll instead of doing nothing.

* docs(wiki): correct settings, map and addon docs against 3.4.0

The wiki described settings that no longer exist and pointed at UI labels that
had been renamed. Each correction below was checked against the code.

- The Settings tab is labelled **General**, not "Display"
  (shared/src/i18n/en/settings.ts:6). Every "Settings → Display" path was wrong.
  The *display currency* setting keeps its name — only the tab was renamed.
- Colour mode is on the **Appearance** tab, not Display.
- The "Route calculation" setting does not exist: no hits for route_calc /
  routeCalc / auto_route / calculateRoutes anywhere in client or server.
- Default map centre and zoom were removed in 3.4.0 (0f4766e1). Replaced with
  what actually happens now, written from client/src/utils/mapViewport.ts:
  every map frames itself on its own places, world view when a trip has no
  coordinates.
- A third map provider, MapLibre GL / OpenFreeMap, was undocumented. It needs no
  access token, which is the reason a reader would choose it over Mapbox.
- Budget-Tracking.md said the feature is called Costs everywhere and then told
  the reader to open the "Budget" tab. The screenshot disclaimer is gone too —
  the images now show Costs.
- The admin tab table was missing Plugins.
- The `packing` addon is seeded as "Lists", not "Packing list management".
- Install docs pinned `mauriceboe/TREK:3.0.15` as the exact-release example,
  four minor versions stale.

* docs(wiki): document passkeys, calendar feeds, appearance, plugins and help

Five shipped features had no user documentation at all:

- **Passkeys** — WebAuthn enrolment and sign-in, admin policy, RP ID/origins.
  Previously mentioned only in passing in Environment-Variables.
- **Calendar Feeds** — the subscribable per-trip and per-user ICS feeds. Note
  Day-Plans-and-Notes documents only the one-off .ics *export*; the two are
  cross-referenced so it is clear which is which.
- **Appearance Settings** — the whole tab, including the custom accent colour
  and its contrast check.
- **Admin: Plugins** — installing from the registry, the pre-install permission
  review, egress hosts, and what Reviewed/Signed/Unsigned actually guarantee.
- **In-App Help** — that the wiki ships in the image and is served from disk
  since 3.4.0, with the GitHub fallback and TREK_WIKI_DIR.

One deliberate deviation from the brief: the Appearance settings are documented
as account-level, not per-device. They persist to /api/settings on the user
account with nothing in localStorage, so the dashboard widget picker's
desktop/mobile split still changes both from either device.

All five are listed in _Sidebar.md.

* docs(wiki): add screenshots for the remaining surfaces

Second capture pass, bringing the run to 42 screenshots. Adds the surfaces that
need more than a navigation to reach: collection and journey detail, MCP access,
two-factor setup, the settle-up payment dialog, and the trip file manager.

Two fixes to the harness itself, both of which had produced misleading images:

- The admin captures showed a "Dev: Notifications" tab that only exists when the
  server runs NODE_ENV=development. The run now clears dev_mode in the
  /auth/app-config response; switching the server to production instead would
  have enabled HSTS and broken the run over http://localhost.
- The settle-up capture clicked "Settle up", which does not open a view — it
  records the transfers. It zeroed every balance and photographed "Everyone's
  square", and because the specs share one database it poisoned Costs.png in the
  same run. Screenshot specs must not mutate state; it now captures the
  "Add payment" dialog instead.

Koffi is installed from the community registry rather than dev-linked, since
dev-link and sideload both badge the plugin card in a way no ordinary install
does.

* test(e2e): capture the detail pages and dialogs

Adds the second wave of screenshot specs (collection/journey detail, MCP access,
2FA, settle-up dialog, files) and enables the mcp, documents and collab addons
in the seed so their surfaces render instead of 404ing.

* docs(wiki): point plugin authors at the agent skill and the registry

Plugin-Development jumped straight into scaffolding without saying that two
supporting resources exist. TREK-Plugins was referenced only in passing far down
the page, and Plugin-Skill — an agent skill that teaches Claude Code and other
SKILL.md-compatible agents to build and publish a plugin — was not mentioned
anywhere in the wiki.

Both are called out up front, with a note that neither is required: the registry
only matters once you want other instances to find your plugin.

* test(e2e): capture the four collab surfaces separately

One Collab.png illustrated chat, notes, polls and the What's Next widget, so at
most one of those four wiki pages showed the feature it described. Each now has
its own capture.

Two things the collab seed needed:

- The conversation is posted by three different people. Every collab write is
  attributed to the acting user, and a single-voice chat log would misrepresent
  the feature outright.
- Each member therefore gets its OWN request context, created with an explicit
  `storageState: undefined`. Without that, newContext inherits the project's
  storageState — the admin's trek_session cookie — and extractToken reads the
  cookie BEFORE the Authorization header (server/src/middleware/auth.ts:9). The
  posts still return 200; they are just all recorded as the admin. That is
  exactly what happened on the first attempt, and the DB was the only place it
  showed.

The collab view is not tabbed — CollabPanel renders all panels at once — so the
captures target cards by seeded content rather than clicking tabs or matching
headings, whose DOM text is 'Notes'/'Polls' while CSS renders them uppercase.

* docs(wiki): show every screenshot on the page it belongs to

Finishes the wiring the screenshot commits deliberately left out.

- Embeds the 14 images that were committed but displayed nowhere: the Costs
  panel and settle-up dialog, the trip planner, transports, documents,
  collection and journey detail, the notifications inbox, the Offline and
  Account settings tabs, appearance, admin user defaults, registration and
  password reset.
- Splits the four collab pages onto their own images. Chat, Notes, Polls and
  What's Next each showed the same Collab.png until now; the overview shot moves
  to Real-Time-Collaboration, which had no image at all.
- Day-Plans-and-Notes pointed at TripPlaner.png — one 'n'. It now uses the
  correctly spelled file, and the misspelled one is deleted since nothing else
  referenced it.
- Removes 45 dead '<!-- TODO: screenshot -->' markers whose screenshot had long
  since been added. 9 remain, each on a page that genuinely still lacks the
  image it asks for — so the marker means something again and the gap is
  greppable, which is how this drifted unnoticed for three months.

Every asset in wiki/assets/ is now referenced by a page, and every image
reference resolves to a file.

* docs(wiki): add the four collab screenshots

Chat now shows a real three-person conversation rather than one voice talking to
itself, and the poll shows three separate votes across two options.

44 images, 4.6 MB total.

* test(help): point the asset test at the correctly spelled screenshot

The integration test hard-coded assets/TripPlaner.png — one 'n' — so deleting
the misspelled file broke it. It was the only thing keeping that filename
alive.

* docs(wiki): regenerate the screenshots on top of the rebased dev

ba3733da changed the fresh-instance defaults to celsius/metric/24h. Every
capture showing a clock — chat timestamps, bookings, day plans — and the General
settings tab itself were still on the 12-hour clock, so 39 of 44 images needed a
new run. Regenerating them is one command, which is the point.

The seed keeps pinning the units explicitly: it now matches the new defaults, but
stating them keeps the captures reproducible if a default moves again.

* docs(wiki): regenerate screenshots after rebasing onto dev

dev added a Catalan translation, an always-show-booking-routes account setting
and a bulk route toggle in the day-plan toolbar since the last run — all visible
on captures we ship. 15 of 44 images changed.

Also resolves the Map-Features conflict from 41d12e89: upstream's new bulk-options
section is kept, with the two 'Settings → Display' paths corrected to 'General'.
The tab is labelled General (shared/src/i18n/en/settings.ts:6), and upstream's own
i18n key for that section is settings.general.travelMap.

* chore: only allow manual trigger for the build&push

* feat(mobile): rebuild Settings & Admin panels natively

* feat(mobile): native booking, transport & expense form sheets

* feat(mobile): unify empty states, streamline nav, collab & collections

* feat(mobile): white atlas bucket button + custom month/year picker

* feat(mobile): show accommodation route legs in the day plan

* feat(mobile): auto-draw the day route in map mode

* feat(mobile): move the vacay invite button left of the year switcher

* fix(mobile): don't render the global dock inside the trip planner

* feat(mobile): reachable add-list, roomier packing rows & owner avatars

* fix(mobile): use the custom date picker for the to-do due date

* feat(mobile): open a running trip on today's day

* chore(client): serve the PWA manifest in dev for on-device testing

* fix(i18n): complete the Catalan (ca) locale for the mobile rewrite

* test(transport): match the shortened Manual/Automated switch labels

* test(mobile): mock the admin permissions panel MAdmin renders

* feat(vacay): rework the desktop vacation planner in the glass style

Move the calendar, sidebar cards and stats onto the --vg-* glass tokens,
soften the person-day fills, pad every month to six week-rows so the
cards line up, tighten the sidebar spacing and add You/Pending badges to
the people list.

* feat(vacay): redesign the vacation settings dialog

Widen the modal and split the options into two columns, and restyle the
holiday calendar rows, inputs and add button to match the rest of the
planner.

* feat(journey): rework the journeys dashboard in the glass style

Move the hero to a blur-mask magazine cover, put the journey cards on
glass with cover/title overlays, widen the grid to match the rest of the
app, drop the top toolbar, and add a subtitle field to the create flow.

* feat(journey): modernise the journey detail page

Centre and widen the feed+map layout, restyle the hero with a frosted
cover, glass stat pill and round controls, turn the view switch into a
glass segmented pill with a labelled add-entry button, and give the
timeline day headers, entry cards, suggestion cards and the entry
editor a rounder, glassier look.

* feat(journey): lay out the journey settings dialog in two columns

Widen the dialog and split the options into two glass cards, and round
the inputs and the save/cancel buttons.

* feat(journey): lay out the entry editor in two columns

Widen the new/edit entry dialog and split it into two columns — title,
photos and the story on the left (the story now grows to fill the
column), date/location, pros & cons, mood and weather on the right.

* feat(journey): polish the detail, settings and share UI

Move the gallery upload into the view switch as a matching pill and drop
the old button, badge the day-place and photo counts, tidy the entry
editor location field, round the settings inputs and buttons, restyle the
contributor role and share controls, and put delete next to copy.

* feat(journey): always feature a hero on the frontpage

The desktop frontpage only rendered the hero header when a journey trip was currently live, so with no live trip there was no header at all. The hero now always features one journey — the live one when there is one, otherwise the most recent — and its eyebrow reads "Latest Journey" when the featured journey is not live.

* feat(mobile): customizable bottom navbar layout

Add a Mobile section under Settings → Appearance where you arrange the bottom navbar: reorder items and choose which sit in the bar versus under the "More" popover, with a live preview. Dashboard stays pinned first. The layout persists per-user in the appearance config and drives MBottomNav; an un-customised account keeps the current Dashboard + Vacay/Atlas dock.

* fix(mobile): widen the nav customizer zone buttons to a single row

* refactor(mobile): show the nav customizer only in mobile settings

The bottom-nav layout only exists on phones, so configuring it from the desktop settings made little sense. Drop the desktop customizer (and its two now-unused labels) and keep the editor on the mobile appearance screen.

* fix(mobile): neutral tiles and no subtitle in the bottom-nav More menu

* feat(desktop): TREK mascot empty states + trip-open splash

Bring the mobile TREK mascot to desktop as a shared EmptyState (mascot + a single title, no subtitle, one uniform look) across the app empty states: reservations, transport, costs, packing, to-dos, files, collab notes/polls/chat, upcoming activities, the day plan, journey timeline and gallery, collections, atlas, dashboard and notifications. The trip planner now plays the same little mascot journey while a trip loads.

* feat(desktop): animated glassmorphism background for the trip loading splash

The trip-open splash now takes over the viewport with a calm, slowly drifting pastel gradient wash (soft pink/lavender/periwinkle, toned down), a gently breathing hue veil, a whisper of filmic grain and a soft vignette, with the mascot journey floating on a frosted liquid-glass card. Theme-aware for light and dark; the background freezes under prefers-reduced-motion.

* fix(planner): keep the empty-day itinerary as small text, no mascot

* fix(mobile): cap the bottom-nav dock at Dashboard + 2 so the More slot fits

* feat(mobile): reorderable dashboard arrangement

Add a mobile-only dashboard order control under Settings → Appearance → Mobile: reorder how the trip list and the inline widgets (currency, collections, world clocks, upcoming reservations) stack on the phone dashboard — e.g. move the currency converter above your trips. The featured trip always stays pinned on top. Stored per-user in the appearance blob (dashboard.mobileOrder); an un-arranged account keeps the current order.

* feat(plugins): map layer provider hook for the trip map

Plugins can now draw bounded vector overlays (polylines, polygons, metric
circles) on the trip map via hooks.mapLayerProvider — routes, reachable-range
corridors, zones. Same contract as map markers: declarative data only, tone
palette styling with clamped numerics, hard per-plugin budgets (4 layers /
150 features / 8000 vertices), oversized or partly-invalid shapes dropped
whole. Rendered beneath the core day route.

The GL renderer now also mounts the plugin markers it was missing — both
hooks work on Leaflet and Mapbox/MapLibre, desktop and mobile.

* feat(plugins): route provider hook with planner route profiles

A routeProvider plugin can now register routing profiles (capabilities.
routeProfiles, max 3) that show up in the planner's route toggle next to
Driving/Walking — e.g. EV routing with charging stops. Selecting one asks
exactly that plugin to route the day's stops (POST /api/plugin-routes,
20 s budget for external solvers); the result is validated whole (leg
count must match the waypoint pairs, vertex/via caps) and rendered like
an OSRM response: geometry on the map, via points as stops on the line,
per-leg times + an optional note ("25 min charge") on the connectors.
A failing provider degrades to straight lines, same as an OSRM outage.

The feed only offers profiles whose hook grant is actually recorded, and
the route endpoint re-checks provider + declared profile against the DB
row, so a hand-edited capabilities blob can't invent routable profiles.

* feat(plugins): day schedule contributions in the planner

A dayScheduleProvider plugin can attach time entries to the day plan —
"35 min charging at this stop", "45 min security before this flight".
Rows are host-rendered under their anchor (a place/booking row, or a
day's start/end) on both the desktop sidebar and the mobile timeline,
and the contributed minutes are folded into the day's route-footer
total as "+X min".

Because this output feeds displayed timing, the server clamps minutes
to a day, checks every dayId against the trip's own days and budgets
the item count per provider; labels go through the usual sanitize+cap
pipeline.

* feat(plugins): geolocation bridge permission

New geolocation:read permission — the first bridge-level grant: a plugin's
sandboxed frames can ask the HOST for the browser position over postMessage
(window.trek.geolocation.get()/watch()). The sandbox itself never gains the
geolocation API; the host page reads navigator.geolocation, so the browser's
own site permission prompt still applies on top of the admin consent, and
the position only travels parent -> frame, never to the server.

The feed flags granted plugins, an ungranted frame gets a 'forbidden'
answer without touching the browser API, one GPS watch per frame, and the
watch is force-stopped when the frame closes. The dev preview answers with
a fixed position so trek.geolocation works without a prompt.

* fix(mobile): render trip-page plugin tabs

The mobile trip shell listed plugin tabs in the Mehr sheet and let the user
open them, but MTripTabPanel had no plugin: branch — the tab came up as an
empty scroller. Mount the same sandboxed PluginFrame the desktop planner
uses, filling the panel above the dock.

* test(plugins): add granted_permissions to the feed fixture schema

The plugins feed now selects granted_permissions (route profiles + the
geolocation flag are gated on the recorded grant), so the hand-written
in-memory schema this suite shares needs the column.

* fix(map): don't crash the trip map when the pane API is absent

PluginMapLayers called map.getPane/createPane unconditionally, which threw
'map.getPane is not a function' on a minimal Leaflet map (breaking MapView
in tests and any renderer state without pane support). Pane placement is a
z-index optimization — guard it and fall back to drawing the plugin
features without the dedicated pane when it isn't available.

* harden(plugins): bound normalize work, scope route cache, enforce geo grant live

Adversarial audit of the four new hooks found no trust-boundary defect; these
close the low-severity gaps it confirmed:

- map-layers / day-schedule / plugin-routes: slice the raw provider array before
  iterating, so an all-invalid oversized payload can't force an unbounded scan on
  the host event loop (the output was already capped; this bounds the work too).
- RouteCalculator: a plugin route is trip-/day-specific, so its cache key now
  includes tripId/dayId — two days with identical coordinates no longer share one
  plugin route (its charging stops/notes differ per day).
- PluginFrame: a live geolocation watch re-checks the grant on every fix and stops
  at once if an admin revokes geolocation:read while the frame stays open.

* test(client): update stale assertions to the mobile-rewrite UI

The mobile rewrite moved empty states to the shared EmptyState mascot
component (title only, no subtitle) and replaced the loading gif with
TripLoadingSplash, but several tests still asserted the old copy/markup:

- CollabChat / PackingListPanel / ReservationsPanel / AtlasPage /
  JourneyDetailPage: assert the current empty-state (title + scene mascot)
  instead of the removed hint paragraphs.
- TripPlannerPage: mock TripLoadingSplash (its mascot-cycling setInterval
  never drains under fake timers, aborting 14 splash-gated tests) and
  assert the current loading node.

Test-only — no component behavior changed.

---------

Co-authored-by: jubnl <jgunther021@gmail.com>
Co-authored-by: Dieter Blomme <dieterblomme@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: sld272 <zjrdmczh@outlook.com>
Co-authored-by: Nguyen Trong Binh <nguytb15@VN1N07HO1CD1015.local>
Co-authored-by: Pavel Zolotarevskiy <code@fxgn.dev>
Co-authored-by: jubnl <66769052+jubnl@users.noreply.github.com>
Co-authored-by: Azalea <noreply@aza.moe>
Co-authored-by: Uzini <43294422+Uziniii@users.noreply.github.com>
Co-authored-by: Daniel <drmoreno271@gmail.com>
Co-authored-by: trongbinhnguyen <43725147+trongbinh15@users.noreply.github.com>
Co-authored-by: Konstantinos Thermos <info@subdee.org>
Co-authored-by: Konstantinos Thermos <subdee@users.noreply.github.com>
Co-authored-by: Lucas Español <lucas.espanol@tutanota.com>
Co-authored-by: fbnlrz <frlrnzn@gmail.com>
2026-07-20 20:44:12 +02:00
Konstantinos Thermos 229ec6d18e fix(notifications): RFC 2047-encode ntfy header umlauts (#1621) 2026-07-20 13:32:55 +02:00
Fabi 0b4737448d Clarify terminology for car rentals in documentation (#1617)
* Clarify terminology for car rentals in documentation

Changed wording for Car / Car-Rentals

* Clarify car rental terminology in transport section
2026-07-20 11:17:51 +02:00
Maurice adbee5aa13 ci: use trek-release app token for version-bump push, annotate release tag 2026-07-19 20:52:43 +02:00
github-actions[bot] a099465889 chore: bump version to 3.4.1 [skip ci] 2026-07-19 17:30:21 +00:00
jubnl 91095ef96a v3.4.1 (#1606)
* fix(transit): lead arrive-by results with the latest arrival (#1479)

MOTIS returns arrive-by itineraries ascending with the deadline-adjacent
connection last, so the visible top of the list arrived ~2h before the
requested time — misread as a timezone bug. Sort arrive-by results by
endTime descending so the connection closest to the requested arrival
leads, mirroring depart-by.

Claude-Session: https://claude.ai/code/session_01BCrZUoPzHZF6H4C6avMXoz

* fix(transit): lead arrive-by results with the latest arrival (#1479)

MOTIS returns arrive-by itineraries ascending with the deadline-adjacent
connection last, so the visible top of the list arrived ~2h before the
requested time — misread as a timezone bug. Sort arrive-by results by
endTime descending so the connection closest to the requested arrival
leads, mirroring depart-by.

* fix(reservations): run AirTrail modal hooks before the isOpen early return (#1602)

The sectionItems useMemo added by #1535 sat below 'if (!isOpen) return null'.
The modal mounts closed, so the first open render executed one more hook
than the previous render and React unmounted the whole tree (error #310),
blanking the page. Move the memo above the early return and cover the
closed-then-open transition with a regression test.

* fix(planner): only draw the check-in-day hotel morning leg when provably at/after check-in (#1597) (#1607)

On an accommodation's check-in day the hotel -> first-stop leg was drawn
by default for any PLACE first stop, suppressed only when that place was
explicitly timed before check-in. An un-timed first place (e.g. "Home"
on day 1 of a driving holiday) therefore always produced a phantom
hotel -> Home leg, regardless of the check-in time.

Flip the default to mirror shouldDrawEveningLeg: the morning leg is now
drawn only when the first place is provably timed at/after check-in
(you dropped your bags first). The drawn map route, the sidebar hotel
connectors, and the Google Maps export all share this helper and
inherit the fix. The optimizer anchors (#1321) are unchanged.

* fix(notifications): never fall back to the admin ntfy topic for per-user sends (#1608)

* chore: correct shields.io url

* chore: correct shields.io url

* chore: update helm repo link

* chore: document new helm chart url

* chore: document new helm chart url

* fix(atlas): make Kosovo selectable on the Atlas map (#1609)

Kosovo's user-assigned ISO code (XK/XKX) was missing from both code
tables in the atlas pipeline: the geo builder's A3_TO_A2 map (so the
shipped admin0 bundle carried ISO_A2: null) and the client's A2_TO_A3
map. With neither resolvable, onEachFeature attached no hover/click
handlers to Kosovo's polygon and the country search dropped it, making
Kosovo impossible to select.

- add XK<->XKX to the client A2_TO_A3 table and the builder A3_TO_A2 map
- stamp ISO_A2: "XK" on the Kosovo feature in the shipped admin0 bundle
- guard both with tests (atlasModel resolution + ATLAS-BUNDLE-003)

* chore: normalize docker image references to mauriceboe/trek (lowercase)

* fix(memories): honor Synology skip-SSL on photo streaming (#1611)

The synology_skip_ssl setting was forwarded on the JSON API path
(login/browse/test-connection) but not on the image-byte fetches, so a
NAS with a self-signed certificate passed Test Connection while
/api/photos/:id/thumbnail and /original failed with 500
"Failed to fetch asset" — and the TLS error was silently swallowed.

- pipeAsset: accept fetchOptions and forward to safeFetch; log the
  underlying error on the 500 path (without the URL, which carries _sid)
- fetchSynologyThumbnailBytes / streamSynologyAsset: pass
  rejectUnauthorized derived from synology_skip_ssl; log on failure
- i18n: replace hardcoded "Immich" with {provider_name} in 7 memories
  keys across 14 locales (source of the reported "Failed to connect to
  Immich" banner on the Synology test), fix translated placeholder
  names in tr/ca/pl, and add a placeholder-parity spec to prevent
  regressions
- client: gallery provider badge matched 'synology' instead of the real
  id 'synologyphotos', showing the raw id; label is now
  "Synology Photos"

* chore: make chart.liketrek.com the canonical helm chart url

* feat(plugins): prefer the registry's resolved store screenshot (#1613)

Browse/detail use the screenshotUrl the aggregate step resolves (cover at the latest commit, or the first resolving README image), falling back to the docs/screenshot.png guess when the field is absent. Fixes blank store cards for plugins without a committed docs/screenshot.png.

---------

Co-authored-by: Maurice <61554723+mauriceboe@users.noreply.github.com>
2026-07-19 19:00:40 +02:00
jubnl 3ca1ef34bb fix: egress policy 2026-07-18 23:00:04 +02:00
jubnl e6c3dc3e46 bump sdk 2026-07-18 22:52:28 +02:00
github-actions[bot] 55fa668dd2 chore: bump version to 3.4.0 [skip ci] 2026-07-18 20:51:16 +00:00
jubnl f1b585727a fix: build 2026-07-18 22:49:56 +02:00
jubnl 9de75b29ca Revert "fix(ci): lowercase docker image name, make release re-runnable, push version tag"
This reverts commit 5134fd9459.
2026-07-18 22:47:43 +02:00
Maurice 5134fd9459 fix(ci): lowercase docker image name, make release re-runnable, push version tag 2026-07-18 22:44:31 +02:00
github-actions[bot] 3e47622b00 chore: bump version to 3.4.0 [skip ci] 2026-07-18 20:28:02 +00:00
jubnl a2bd9be184 v3.4.0 (#1540)
* 3.3.0 (#1472)

* feat(plugins): grow the frame bridge — fill pages, confirm, openExternal, live context

- page/trip-page hosts pass fill: the frame pins to 100% height and ignores
  trek:resize, so a kit plugin's auto height report no longer collapses a full
  page into a floating island with dead space below (widgets keep self-sizing)
- trek:confirm renders the native ConfirmDialog host-side (the sandbox has no
  allow-modals) and answers trek:confirm:result; one at a time
- trek:openExternal opens validated http(s) URLs in a noopener tab — the
  sandbox has no allow-popups, so plugins simply couldn't link out before
- trek:notify accepts an optional duration, clamped to 1.5-15s
- context gains dir (rtl/ltr) and is re-pushed when locale or format settings
  change, not just on appearance mutations
- core events for the trip in view are forwarded as trek:event — names only,
  never payloads, mirroring the server-side events surface

* fix(plugins): move trip warnings out of the content area

The warning pills overlaid the top of every planner tab at full width, sitting
on the map and its toolbar. Now a warning from a plugin that owns a trip-page
tab renders as a compact chip in the navbar centre (click jumps to the tab; the
navbar centre is free on trip pages), and everything else floats above the
content at the bottom instead. Mobile keeps all warnings in the bottom overlay
since the desktop navbar isn't there. The trip tab's frame also opts into the
new fill mode.

* feat(plugin-sdk): 1.4.0 — motion library + new bridge helpers in the kit

Mirrors the host's animation vocabulary 1:1 into TREK_UI_CSS (menu/popover/
modal/backdrop/toast enters, drawer variant under 640px, page-enter, stagger,
skeleton shimmer, chart reveals) including the reduced-motion degrade to a
gentle fade. window.trek grows confirm(), openExternal(), onEvent() and a
notify duration, and applyContext now stamps lang/dir on the document so RTL
hosts get RTL plugin UIs.

* feat(plugins): surface registry download counts in browse

The registry now aggregates GitHub release download counts per plugin
(TREK-Plugins#18) as an entry-level downloadCount. Project it through
browse/detail and show it as a compact stat on the browse cards and in the
detail meta grid. Counts are raw asset downloads (updates and CI included),
so the UI says downloads, not installs.

* docs(plugins): document the grown bridge surface and motion classes

* fix(plugins): harden the new bridge paths

Review pass over the bridge additions:

- keep the unstable useToast() object out of the effect deps (ref instead) —
  it re-created the effect on every parent render, and with the new live
  repost that meant a trek:context flood into the frame
- reset loads/height/confirm state and key the iframe when a host swaps
  pluginId in place (tab bar, /plugins/:id) — the new plugin's document was
  refused as a 'navigated' frame and every kit promise hung
- confirm dialogs always lead with the host-controlled plugin name so a
  plugin can't dress its dialog up as a TREK system prompt; answer/refuse
  moved out of setState updaters (StrictMode ran them twice)
- Number.isFinite on the notify duration (NaN parked a sticky toast)
- don't forward other plugins' namespaced broadcasts as trek:event; a
  plugin's own plugin:{id}:* broadcasts now reach its frame though
- teach the SDK dev preview the confirm/openExternal contract so
  trek.confirm() resolves in /preview
- 999,950 downloads formats as 1M, not 1000k

* fix(planner): let plugin warning chips grow wider before truncating

The nav-centre chip capped at 340px, so a longer warning (e.g. the TREK x
Japan weather prompt) was ellipsised almost immediately. Scale it with the
viewport up to 520px so most messages read in full while still yielding on
narrow desktops.

* feat(plugins): sort the plugin browser by download count

Discover now honours the sort dropdown (it was always alphabetical) and adds
a 'Most downloads' option that ranks the registry by downloadCount. The sort
keys are scoped per tab — updates-first stays with Installed, most-downloads
with Discover — and snap back to name when the tab can't offer them.

* feat(plugin-sdk): auto-upgrade native <select> to a host-styled dropdown

A sandboxed plugin can't reach the host's components, and a native <select>
draws its popup from the OS — so plugin dropdowns never matched TREK. The design
kit now enhances every <select> into a keyboard-accessible listbox that uses the
kit tokens, keeping the real element as the value/form source (it still fires
change). Authors write a plain <select> and get the host look for free; opt a
field out with data-trek-native. validate warns when a plugin ships a <select>
without inlining the kit.

* feat(plugins): add issue url link

* feat(plugins): reservations write + cross-trip reads

- db:write:reservations -> reservations.create/update/delete, gated exactly like
  the REST/MCP path (reservation_edit + trip membership, acting user host-bound,
  no impersonation) and delegating to ReservationsService so the accommodation,
  budget-sync, booking-notification and reservation:* broadcasts match the web
  app 1:1 — a booking/flight/import plugin can finally write a reservation
- trips.listMine / reservations.listMine: enumerate every trip and booking the
  acting user can access (membership baked into listTrips, never a raw
  cross-tenant SELECT) — dashboards/aggregates were impossible before
- audit: derive auditability from METHOD_PERMISSION so a new capability method
  can't be added un-audited by omission
- typed ctx.reservations.* / ctx.trips.listMine, perm label (en/de), wiki

* feat(plugins): read scopes for journal, atlas, vacay and day notes

- db:read:journal / db:read:atlas / db:read:vacay expose the acting user's OWN
  journals / visited countries+regions / vacation plan across all their trips
  (user-scoped like costs.listMine, each gated on its addon being enabled),
  reusing the addon's existing readers
- db:read:daynotes -> daynotes.list(tripId, dayId), trip-scoped and
  membership-checked like the other trip reads
- typed ctx.journal / atlas / vacay / daynotes, perm labels (en/de), wiki,
  audit resource labels, tests

* feat(plugins): day notes write scope

- db:write:daynotes -> daynotes.create/update/delete, gated under the app's
  'day_edit' permission (like days) with the day verified to belong to the trip;
  reuses dayNoteService and broadcasts the same dayNote:* events so open
  sessions update live
- typed ctx.daynotes.create/update/delete, perm label (en/de), wiki, tests

* feat(plugins): run declared background jobs on a schedule

- plugins already declared jobs {id, schedule} but the cron was never wired. The
  host now schedules them: host-entry reports each job's schedule, the supervisor
  starts the jobs (node-cron) when the plugin goes active and stops them on
  kill/deactivate so nothing leaks
- opt-in via a new jobs:run permission — scheduled work runs with NO acting user
  (its trip reads stay refused; it can only use ctx.db and declared egress), so
  background execution is a distinct, admin-granted capability. Invalid crons are
  skipped and a throwing job can't break the host
- extracted a small, unit-tested scheduler (plugin-jobs.ts); perm label (en/de),
  wiki, tests

* feat(plugins): read scope for saved-place collections

- db:read:collections -> collections.listMine() / collections.get(id): the acting
  user's own collections (user-scoped, gated on the Collections addon), reusing
  collectionsService
- typed ctx.collections, perm label (en/de), wiki, audit resource labels, tests

* fix(plugins): translate new permission labels to all locales + cover the new wiring

- add the 8 new admin.plugins.perm.* labels (reservations/day-notes writes, the
  journal/atlas/vacay/day-notes/collections reads and jobs:run) to the remaining
  20 locales so the strict i18n key-parity test passes again
- cover the create-rpc-host reservation / day-note / cross-trip / addon-read deps
  (the real side-effect wiring the mocked rpc-host tests don't exercise) so the
  src/nest 80% branch-coverage gate holds

* feat(plugins): dev-link — hot-reload a local plugin against real data

Answers a plugin developer's ask: today you either get `trek-plugin-sdk dev`
(fast hot-reload but MOCK/fixture data) or the full build->pack->upload->activate
cycle (real data, no watcher). Neither gives "local dir + hot-reload + real data".

- POST /admin/plugins/link registers a plugin from a LOCAL built directory by
  symlinking it into the plugins volume — the loader already forks the resolved
  real path, so ZERO loader change — and registering it INACTIVE as `local:link`.
  Validates the manifest + refuses native binaries exactly like a sideload.
- POST /admin/plugins/:id/reload re-forks a linked plugin via the existing
  deactivate->activate primitive (same grants, no re-consent unless the manifest
  widened perms). A best-effort fs.watch auto-reloads on rebuild.
- It runs through the UNCHANGED capability RPC host: real, membership-gated data,
  acting user host-bound, no impersonation — code origin never touches the gate.
- Gated behind TREK_PLUGINS_DEV_LINK on top of admin + kill-switch, because a
  linked plugin bypasses the install-time signature model and, under `npm run
  dev`, the OS jail is off. Off by default; never reachable in production.
- discovery follows a symlinked <root>/<id>; uninstall/link never delete the
  author's source (link-safe removal for POSIX symlinks and Windows junctions).

* docs(plugins): document the dev-link real-data hot-reload workflow

Adds a "Test against a real instance's data (dev-link)" subsection next to the
mock-data SDK preview: TREK_PLUGINS_DEV_LINK, POST /link with a local built dir,
activate + consent, hot-reload via the file-watch / POST /:id/reload / Restart,
and the dev-only security caveats.

* feat(plugins): dev-link admin UI

- surface devLink (TREK_PLUGINS_DEV_LINK) in GET /admin/plugins so the panel shows
  the link form only where dev-link is enabled
- AdminPluginsPanel: a "Link a local plugin" form (path -> POST /link), a Dev-Link
  badge for source_repo=local:link, and adminApi.pluginLink/pluginReload
- fix: the plugin menu treated any non-local:upload source_repo as a GitHub repo,
  so a dev-linked plugin rendered github.com/local:link links — exclude local:link
- labels for the 6 new dev-link UI strings across all 22 locales

* docs(plugins): document the dev-link admin UI

The dev-link section showed only the curl call — surface the Admin → Plugins
"Link a local plugin" field (the primary path) and the Dev-link badge, with curl
kept as the scripting alternative.

* feat(plugins): enrich core events with { entity, entityId }

Subscribed plugins now learn WHICH entity changed, not just the event name — a
reservation/place/day/... id derived host-side from an explicit per-family
whitelist. Threaded through the six event hops WITHOUT touching actingUserId: the
handler still runs with no user, so the id is not dereferenceable (a trip read is
still refused; the id says what to react to, not what it contains). A non-entity id
can never surface — budget:member-paid-updated yields the itemId, never the userId
— and bulk/reorder/sub-entity payloads carry no id. The mapper is pure, synchronous
and never throws into the core broadcast. No new permission (reuses
events:subscribe); backend-only.

* feat(plugins): packing write scope with #858 privacy-scoped broadcasts

- db:write:packing -> packing.create/update/delete, gated under the app's
  'packing_edit' permission (like the REST path) with the host-bound acting user
  as owner; reuses packingService
- replicates the packing privacy model 1:1 (the controller/service helpers aren't
  exported): create/delete fan out to the item's viewers only (owner + recipients,
  or the whole room for a Common item); update runs the four public<->private
  transitions, dropping a freshly-privatized item from the room BEFORE re-adding it
  owner-only so it never leaks. A stale write is BAD_PARAMS with no broadcast
- typed ctx.packing.create/update/delete, perm label (22 locales), wiki, tests
  (rpc-host gating + the four transitions + owner-scoped delete)

* feat(plugins): tableContributor hook — host-rendered view columns/actions (backend)

The registry backend for plugin-contributed columns/actions in the native planner
views (the tabular-reservations use case), mirroring placeDetailProvider:
- hook:table-contributor + the tableContributor hook (getContributions(view,
  tripId, ctx) -> TableContribution[]), double-gated (implement + grant) like the
  other provider hooks
- GET /api/view-contributions/:view/:tripId — view whitelist + membership gate +
  per-provider timeout/fail-safe, plus the hardening the older provider hooks lack:
  every field is String-coerced + length-capped, kind/tone/target enum-whitelisted,
  per-provider counts capped (<=20 columns / <=10 actions), and a column url must be
  http/https/mailto (a javascript:/data: url is click-XSS into the native DOM)
- typed pluginsApi.viewContributions + the ViewContribution union, perm label
  (22 locales), wiki, hardening tests

* feat(plugins): render tableContributor columns/actions in the reservations view

The frontend for the tableContributor hook: a reusable PluginContributions layer
(usePluginViewContributions + PluginColumns/PluginActions) that renders the
host-normalized column/action leaves NATIVELY — a column is text/badge/link, an
action is a button that calls the plugin route or opens its sandboxed frame in a
modal (plugin markup only ever runs inside the opaque-origin iframe). Wired into the
reservations cards (both ReservationCard and TransitJourneyCard) as a strictly-
additive footer keyed by reservation id: zero change to a card when no plugin
contributes. Fetched once per view, fail-safe.

* docs(plugins): bring the permissions wikis current with this cycle

Plugin-Permissions.md was missing every permission added this cycle — add rows for
the read scopes (journal/atlas/vacay/daynotes/collections), the write scopes
(reservations/daynotes/packing, packing noting the #858 owner-scoping), jobs:run
and hook:table-contributor, and correct the events:subscribe row for the new
{ entity, entityId } hint. Add the jobs:run row to Plugin-Development.md too.

* fix(maps): stop quick one-finger pans zooming the map on mobile (#1440)

The global drag-drop-touch polyfill installs document-level touch listeners
on phones. On every single-finger touchend it records a timestamp, and if the
next touch starts within 500ms it synthesises a dblclick on the target, which
the map's default double-click-zoom turns into a zoom-in. Two quick one-finger
pans therefore zoomed instead of panning.

The polyfill only bridges HTML5 drag-and-drop to touch for planner reordering,
which is already disabled on mobile (#1432), so gate its import to viewports
>=1024px (the lg breakpoint useIsMobile uses). Removes the phantom-dblclick
source on phones while keeping touch DnD on large viewports; fixes both the
Leaflet and GL renderers.

* fix(feeds): emit TZID + VTIMEZONE so subscribed calendars respect time zones (#1453)

exportICS emitted timed DTSTART/DTEND as bare floating times (no Z, no TZID),
which iOS/Google Calendar render in the subscriber's local zone instead of the
zone TREK shows. Resolve an IANA zone per timed event — transport endpoints use
their stored timezone (departure drives DTSTART, arrival drives DTEND), while
assignments and hotel/restaurant reservations derive it from place coordinates
via tz-lookup — and attach TZID backed by a VTIMEZONE component. The all-trips
feed now carries deduped VTIMEZONE blocks so TZID references still resolve.

* feat(plugins): render tableContributor contributions in the places + day views

Extends the tableContributor frontend to all three planner views: hoist the shared
PluginCardFooter into PluginContributions, wire the places sidebar (keyed by place
id, rendered as a sibling after each row so the drag/scroll row stays untouched)
and the day panel (keyed by day id, guarded for a null day). Strictly additive +
fail-safe like the reservations view — nothing renders when no plugin contributes.

* fix(vacay): source holiday subdivisions from ISO 3166-2 so all states show

The state/region picker for public-holiday calendars was built from the
union of each holiday's counties for the current year, so a subdivision
only appeared if some holiday that year was tagged with it. States with
no state-specific holiday (e.g. US-WA, and AR/FL/NV/WY in 2026) silently
vanished, blocking calendar creation (#1456).

Source the full, correctly-named subdivision list per country from
ISO 3166-2 instead, merged with any nager county code ISO lacks. Only
region-partitioned countries get a picker, so nationwide-only countries
keep allowing a country-level calendar. No server change needed —
selecting a state already yields federal holidays via applyHolidayCalendars.

* fix(costs): settlements honor custom per-member splits (#1458)

calculateSettlement read each member's custom split amount but its query
never selected budget_item_members.amount, so hasCustomSplit was always
false and every settlement fell back to the equal split. Select bm.amount
so custom amounts drive the balances.

Also blank the Overview 'Per Person' / 'Per Person·Day' columns and CSV
for custom-split items, where a single averaged figure is meaningless.

* feat(plugins): read-convenience + todos + packing bags + tags + roster

A wave of small, high-value capabilities:
- weather:read (ctx.weather.get) — the host's cached forecast, tenant-free
- db:read:categories (ctx.categories.list) — the global place-category list
- db:read:tags / db:write:tags (ctx.tags) — the acting user's own tags, ownership
  re-checked before each write
- trips.members (ctx.trips.members) — the trip roster (id + display fields),
  membership-checked
- db:read:todos / db:write:todos (ctx.todos) — a trip's to-dos, gated by the app's
  packing_edit like the REST path, broadcasts todo:*
- packing bags on ctx.packing (listBags/createBag/updateBag/deleteBag/setBagMembers)
  under db:write:packing — no privacy, plain room broadcasts
perm labels (22 locales), both wikis, rpc-host gating + create-rpc-host wiring tests

* fix(dashboard): render next-trip boarding pass stats on Safari (#1459)

The boarding-pass bar carved its ticket-stub notches with a two-layer
radial-gradient mask composited via mask-composite: intersect (and legacy
-webkit-mask-composite: source-in). Safari mis-composites that multi-layer
path to fully transparent, hiding the entire stats bar while Chrome renders
it fine.

Split .hero-pass into an outer wrapper (left notch) and a .hero-pass-inner
glass panel (right notch), each carrying a single-layer mask so the
mask-composite path is never exercised. Renders identically across engines
and degrades safely where mask-image is unsupported.

* feat(plugins): write scopes for atlas, vacay, journal and collections

The write half of the user-scoped addon reads:
- db:write:atlas -> ctx.atlas.markCountry/unmarkCountry/markRegion/unmarkRegion +
  bucket-list create/delete. Every row is the acting user's own (visited_countries/
  visited_regions/bucket) — no trip scoping, no cross-tenant surface. Unblocks
  AirTrail-style two-way sync (#214)
- db:write:vacay -> ctx.vacay.toggleEntry/toggleCompanyHoliday. The plan is
  resolved HOST-SIDE from the acting user's active plan — a plugin can never name
  another plan, and toggleEntry only toggles the acting user's own PTO day
- db:write:journal -> ctx.journal.createEntry/updateEntry/deleteEntry, self-gated
  by journeyService.canEdit (owner/contributor) against the acting user
- db:write:collections -> ctx.collections.create/update/savePlace/copyToTrip/
  deletePlace, schema-validated; the service's per-collection role checks
  (assertAccess 404 / assertCanEdit 403) map onto RESOURCE_FORBIDDEN
All addon-gated, userless contexts refused, audited. Perm labels (22 locales),
both wikis, gating + wiring tests.

* fix(admin): name the Costs add-on consistently in the catalog

The budget add-on catalog entry still resolved to 'Budget' while the
feature is labeled 'Costs' everywhere else (trip tab, navbar). Align
admin.addons.catalog.budget.name with each locale's trip.tabs.budget
label. Closes #1464

* feat(plugins): file attach, collab content and gated member-add

- db:write:files -> ctx.files.create/createLink/update/softDelete under the app's
  separate file_upload/file_edit/file_delete rights. Content arrives as bounded
  base64 (10MB decoded cap, well under the app's 50MB), the extension is validated
  against the central blocklist BEFORE anything touches disk, and link targets
  must live on the same trip (findForeignLinkTarget). Broadcasts file:*
- db:write:collab -> ctx.collab.createNote/createPoll/votePoll/createMessage
  under collab_edit + the Collab addon, emitting the same collab:* events as the
  app; service-reported errors surface as BAD_PARAMS
- db:write:members -> ctx.trips.addMember. Adding a member GRANTS TRIP ACCESS, so
  it is deliberately its own permission behind the app's member_manage right
  (default: trip owner only) and never bundled with a lower-risk write; the acting
  user is recorded as the inviter, target must exist, owner/duplicate adds no-op
Perm labels (22 locales), both wikis, gating + wiring tests.

* fix(maps): honor check-in/out times for hotel bookend legs (#1465)

The day route drew the accommodation as the day's start/end whenever the
edge stop was a place, ignoring the morningIsSleptHere/eveningIsOvernight
provenance already computed by getDayBookendHotels. On a check-in day an
airport placed before check-in got a spurious hotel -> airport leg, and on
a check-out day a later "home" stop still got a home -> hotel return leg.

Add time-aware shouldDrawMorningLeg/shouldDrawEveningLeg helpers: the
morning leg is the home-base default on a check-in day but is dropped when
the first place is timed before check-in; the evening return leg is off on
a check-out day unless the last place is timed at/before check-out. Wire
them into the map polyline, the sidebar hotel connectors, and the Google
Maps export so all three stay consistent.

* feat(plugins): host-mediated notifications and LLM access

Two host-owned integration primitives — the plugin supplies intent, the host
owns the sensitive part:

- notify:send -> ctx.notify.send({title, body, link?, scope, targetId}). Delegates
  to notificationService.send with a new plugin_notification event (raw title/body
  carried as passthrough params), so recipient resolution, channel fan-out
  (bell inbox + email/ntfy/webhook) and per-user preferences all match core 1:1.
  Recipients are FORCED to the acting user (scope 'user', targetId === uid) or a
  trip they belong to (scope 'trip'); scope 'admin' refused; the in-app link must
  be a relative /path (open-redirect-safe). No arbitrary recipient, no impersonation.
  Users can mute plugin notifications like any other event.
- ai:invoke -> ctx.ai.complete(prompt) / ctx.ai.extract(text, jsonSchema). Runs the
  admin/user-configured provider via resolveLlmConfig + the existing extraction
  client under the acting user; the host holds the (encrypted) key, the plugin
  never sees it. Refused when no provider is configured; 20k-char caps. Output is
  DATA (complete -> {text}, extract -> {results}) and never auto-written, so
  prompt-injection can't reach a write without the plugin's own gated call.

plugin_notification wired through the shared NotificationEventKey + all 22 locales
(inbox passthrough + external channels). Perm labels (22 locales), both wikis,
gating + wiring tests.

* fix(budget): offer every Frankfurter-supported currency (#1470)

The cost currency picker was gated by a hardcoded 47-code list, so
currencies the app can actually convert (OMR, CRC, UGX, MKD, ALL, and
~115 more) couldn't be selected. Replace CURRENCIES/SYMBOLS with the full
set the Frankfurter v2 FX API supports (archived BGN/HRK dropped), unify
the dashboard offline fallback onto it, and teach currencyDecimals about
the newly reachable zero- and three-decimal currencies. A currenciesWith
helper keeps a previously saved (now-archived) selection selectable so it
isn't silently wiped.

* feat(plugins): tableContributor into the costs, packing and files views

Extends the shipped tableContributor hook to three more native views — no new
permission, no new attack surface: the same host-normalized, length-capped,
url-allowlisted (http/https/mailto), enum-bounded, fail-safe pipeline, just more
render sites.

- server: add costs/packing/files to the view-contributions whitelist
- client: widen the ViewName union + the api view type; render PluginCardFooter
  keyed by entityId in the budget category table (a colSpan footer row per item),
  the packing category group (footer after each item row, drag untouched) and the
  files list (footer after each row)

A currency plugin can now drop a converted-amount column onto a cost row, a
receipts plugin a 'view receipt' action onto a file, etc. Controller test asserts
the three new views are accepted; both wikis updated.

* fix(pdf): repeat day header on overflowing itinerary export pages (#1471)

* feat(plugins): map-marker provider hook — plugins can overlay trip-map markers

New declarative provider hook `mapMarkerProvider` (#587 "show bookings on map",
the single most-requested contribution class, with zero contribution point until
now):

- hook:map-marker-provider permission + MapMarkerProvider/MapMarkerContribution SDK
  types + HOOK_PERMISSION wiring
- GET /api/map-markers/:tripId (MapMarkersController) mirrors the view-contributions
  hardening: membership-gated, providers invoked host->plugin on a 5s timeout,
  fail-safe. Every field normalized server-side — coordinates range-checked
  (-90..90 / -180..180), strings String-coerced + length-capped, icon/tone enum-
  whitelisted, popup url http/https/mailto only (a javascript:/data: url would be
  click-XSS), marker count capped at 200 per plugin
- client: PluginMapMarkers layer renders the markers as plain Leaflet Marker+Popup
  inside the trip map; plugin JS NEVER runs on the map canvas, every value is
  host-vetted data. Threaded tripId through MapView; fail-safe fetch

Declarative-only by design, mirroring placeDetailProvider/tableContributor. Perm
label (22 locales), controller hardening test, both wikis.

* feat(plugins): show page plugins in the mobile bottom nav

Page plugins were reachable from the desktop nav pill (Navbar) but not the mobile
tab bar — you had to type /plugins/:id. BottomNav now reads page plugins from the
plugin store and appends them the same way global addons are, mirroring Navbar.
One-file client nav wiring; no new capability surface.

* feat(plugins): per-user plugin settings form + ctx.settings runtime read

Users can now enter their own per-plugin config (an API key, a preference) —
the prerequisite for almost every real integration, previously unreachable
(scope:'user' settings were only listed read-only in the admin panel).

- migration: plugin_user_config (plugin_id, user_id, config JSON) — each user's
  own values, separate from the admin-owned instance plugins.config
- PluginsService.getUserConfig / updateUserConfig / getUserConfigDecrypted +
  readUserSettingDecrypted: secrets encrypted at rest (apiKeyCrypto), masked to
  the client, an unchanged secret (the mask) keeps its stored ciphertext, and only
  DECLARED scope:'user' keys are ever stored
- GET/POST /api/plugin-settings/:id (PluginUserSettingsController) — its own
  user-gated path (not the admin surface, not the /:id/* proxy), JwtAuthGuard only,
  scoped to the acting user
- runtime: ctx.settings.get(key) -> the acting user's decrypted value (unconditional
  RPC, not sensitive cross-tenant; userless job/onLoad gets undefined)
- client: a Plugins tab in Settings host-renders each active plugin's scope:'user'
  fields as an editable form (secrets write-only), reusing the declarative field
  shape — no plugin markup executes

i18n (22 locales), wiki, rpc-host + service + masking/encryption tests.

* fix(journey): keep skeleton suggestions in sync with linked trip places (#1473)

Journey skeleton suggestions mirror a linked trip's day-assigned places, but
sync relied on scattered per-event hooks that several assignment mutation paths
never called: unassign, move and time-change fired nothing, no remove-on-unassign
capability existed, and every MCP assignment tool synced nothing. Skeletons drifted
from the trip.

Add an idempotent reconcileTripSkeletons(tripId) that re-mirrors the trip's
day-assigned places onto every linked journey (add missing skeletons, refresh
date/time/location on move, remove skeletons for unassigned places; filled entries
are detached + noted, never destroyed). Call it from every REST assignment handler
and MCP assignment tool, and fire onPlaceDeleted on single MCP delete_place for
parity. Extract a shared insertSkeletonEntry helper.

* fix(memories): drop hidden Immich assets so Live Photo motion parts don't show a broken thumbnail (#1474)

* fix(transit): anchor arrive-by search time to the destination timezone (#1479)

* feat(plugins): host-brokered OAuth client + trustworthy inbound webhooks

Two integration primitives where the host owns the sensitive part.

Trustworthy webhooks:
- auth:false routes now receive req.headers, but ONLY an explicit, credential-free
  allowlist (the common provider signature/event headers — stripe-signature,
  x-hub-signature-256, svix-*, x-gitlab-event, …). Cookie/Authorization/X-Socket-Id
  and every session/forwarded-auth header are stripped; authenticated routes get {}.
  A plugin can finally verify a provider signature without any way to leak a session.

Host-brokered outbound OAuth (oauth:client):
- the HOST runs the whole flow — authorize -> callback -> token exchange -> refresh —
  with PKCE + single-use, user-bound, TTL'd state, and HOLDS the tokens. The client
  secret + refresh token never leave the host; the plugin only triggers connect and
  reads a short-lived access token via ctx.oauth.getAccessToken() for the acting user.
- provider config (authorize/token url + scopes + client id/secret) is the plugin's
  admin-owned instance settings; endpoints must be https (SSRF backstop, private/local
  hosts refused). Tokens per-user + encrypted at rest (apiKeyCrypto).
- GET/POST /api/plugin-oauth/:id/{status,connect,callback,disconnect} — JwtAuth-gated,
  the callback always redirects to an in-app /settings path (never leaks an error).
- Settings -> Plugins gains a Connect/Disconnect control per configured plugin.

migration: plugin_oauth_tokens + plugin_oauth_state. Perm labels + form strings
(22 locales), both wikis, service (PKCE/state/exchange/refresh/encrypt) + controller
+ proxy header-allowlist + rpc-host gating + create-rpc-host wiring tests.

* fix(navbar): re-measure sliding tab pill after font load and resize (#1481)

The active tab pill was measured once in a layout effect keyed only on activeTab, so on a hard reload it captured the active (bold) label's width against fallback-font metrics and never re-ran when the web font swapped in, leaving the pill slightly offset.

Re-measure after document.fonts.ready resolves and on ResizeObserver changes (container + active button), with an idempotent state update to avoid redundant renders.

* fix(collections): keep the Add-place button reachable after the first save

On a wide/desktop layout the collection toolbar (which hosts the Add
button) was gated on !mapOverlay, so it unmounted as soon as the list
gained its first place with coordinates — leaving only an easy-to-miss
"+" in the map overlay. Keep the toolbar rendered whenever the user can
add a place, and drop the now-redundant map-overlay Add button so there
is a single, predictable Add affordance in every state.

Fixes #1485

* feat(plugins): days + accommodations reads/writes, endpoints on the reservation write path

Community feedback on the 3.2.1 plugin surface: a plugin could write days but
never list them (no way to learn day ids), day_accommodations had no surface at
all, and trips.getReservations was the one reservation read that dropped the
endpoints/day_positions hydration.

- trips.getDays / trips.getAccommodations under db:read:trips (tripRead gate),
  wired to the same dayService lists the REST GETs use
- trips.getReservations now returns the hydrated REST-parity list (endpoints,
  day_positions, joins, normalized accommodation_id) - strict superset
- new db:write:accommodations scope: ctx.accommodations create/update/delete
  gated by day_edit like the accommodations REST path, with the partner-hotel
  reservation + delete cascade and broadcasts intact
- reservation create/update pin the endpoints shape up front (BadParams instead
  of a mid-transaction NOT-NULL or a silently dropped row)
- perm label in all 22 locales, consent PERM_KEYS, wiki tables

* feat(plugins): day-detail widget slot in the day panel

Widgets can now mount inside the trip planner's day panel
(capabilities.widget.slot: 'day-detail'), scoped to the open day via a dayId in
trek:context - the same pattern as the place-detail slot. Covers the requested
per-day plugin content (logistics, outfit planning, live flight status) without
a new plugin type. Day-detail widgets stay off the dashboard, the consent panel
labels the slot in all 22 locales.

* feat(plugins): let the frame CSP serve a plugin's own static assets

The sandboxed frame runs at an opaque origin, so script-src 'self' never
matched and a plugin's own <script src>/<link> files were blocked - authors had
to inline entire React builds into index.html. Add a scheme-less host-source
pinned to the plugin's own /plugin-frame/<id>/ path (charset-checked Host +
plugin id so a stray token can't widen the policy; malformed Host falls back to
inline-only). Multi-file client builds now load as-is; remote hosts stay
blocked, so script URLs remain useless as an egress channel.

* fix(plugin-sdk): catch the package up to the server capability surface

The npm SDK's validator still knew only the 3.2.1 permission set, so
'trek-plugin-sdk validate' (and pack/publish, which run it) hard-rejected any
manifest using the newer scopes - db:write:reservations, notify:send,
hook:map-marker-provider and 25 more. Sync KNOWN_PERMISSIONS with the server
envelope (48 entries), mirror the full PluginContext (reservations,
accommodations, notify/ai/oauth/settings, packing writes + bags, file writes,
collab, tags/todos/daynotes/collections/atlas/vacay/journal, weather,
categories), type the tableContributor/mapMarkerProvider hooks + the
entity/entityId event hint, accept the day-detail widget slot, and extend
createMockHost so plugin unit tests can exercise all of it.

* feat(plugins): grant-scoped entity snapshots on core events

An events:subscribe handler so far learned only WHICH entity changed - useful
for cache busting, useless for reacting to content, and the userless handler
can't refetch. Now the broadcast tap derives a whitelisted field snapshot of
the changed entity and the supervisor attaches it per plugin, only where the
granted set holds the family's matching db:read:* permission (trips family ->
db:read:trips, budget -> db:read:costs, packing -> db:read:packing, dayNote ->
db:read:daynotes, file -> db:read:files). No acting user is ever synthesized.

The whitelists are explicit per family, so user ids (owner/paid_by/uploaded_by/
participants/members), trips.feed_token and future migration columns never
travel; a private packing item (#858) yields no snapshot at all because its
core broadcast is owner-scoped; deletes/reorders/bulk ops carry none.

* feat(plugins): pdf-section, atlas-layer and journal-entry provider hooks

Three more declarative provider surfaces in the map-marker mould - plugins
return data specs, the host normalizes, caps and renders; a slow or failing
provider contributes nothing:

- hook:pdf-section-provider: sections (title + paragraphs + a simple table)
  appended to the trip PDF export, escaped into the same HTML/print pipeline
  as the core content
- hook:atlas-layer-provider: per-user country tint layers on the Atlas map
  (ISO 3166-1 alpha-2 codes only, tone-whitelisted, non-interactive pane so
  mark/unmark clicks keep working)
- hook:journal-entry-provider: extra rows on a journal entry card, gated by
  the same journey access check as the journal routes + the Journey addon

Permission labels in all 22 locales, consent PERM_KEYS, SDK types + manifest
validator in both SDK copies, wiki tables, per-controller hardening tests.

* feat(plugins): trip-page plugins can replace core planner tabs and pick their spot

A trip-page plugin that takes over a core surface (a transit planner
superseding Transports, a costs plugin superseding the budget tab) had to sit
awkwardly next to the tab it replaces. capabilities.tripPage now names the
core tabs to hide while the plugin is active - whitelisted (transports,
buchungen, listen, finanzplan, dateien, collab), 'plan' deliberately not
replaceable, and the tabs return the moment the plugin is deactivated - plus
an optional 0-based position for the plugin's own tab. The feed re-validates
the values out of the DB blob so a hand-edited row can't hide anything else,
the admin list chips a replacing plugin (all 22 locales), and a saved session
tab that got replaced falls back to the plan view.

Also fixes the plugins feed dropping the day-detail widget slot to 'sidebar',
which would have mounted a day-panel widget on the dashboard.

* fix(plugins): audit follow-ups — normalization, secret cleanup, cron leak, slot filter

Adversarial audit of the whole plugin PR surfaced 12 confirmed issues; this
addresses them:

- place-details provider was the ONE hook controller with no normalization: a
  plugin's href/label/value went to the client raw and unbounded. Now normalized
  like journal-entry-rows (safeUrl http/https/mailto, length + count caps).
- trip-warnings capped message length + per-provider count (was unbounded).
- uninstall(deleteData) now also purges plugin_user_config, plugin_oauth_tokens,
  plugin_oauth_state, plugin_meta_migrations and the capability audit — encrypted
  per-user API keys + OAuth refresh tokens no longer survive a 'delete all data'
  and get silently re-adopted on a same-id reinstall.
- supervisor: a crash-restart cycle leaked the dead child's node-cron tasks and
  re-scheduled fresh ones, so a job fired N+1 times per tick after N crashes.
  onExit now stops them, mirroring kill().
- dashboard sidebar no longer mounts place-detail/day-detail widgets (they belong
  in the planner panels).
- reservation endpoint validation relaxed to match the 3.2.1 service: a coord-less
  endpoint is accepted and dropped downstream instead of BadParams (no breaking
  change), while a bad role/non-string still rejects up front.
- a replaced core tab reached by programmatic nav now falls back to the plan view.
- trips.update caps title/description like the places path; plugin-db guard bans
  load_extension as defense-in-depth.
- wiki: event snapshots, string-typed context ids, dayId in the payload, the live
  provider hooks and the costs update/delete grant are now documented correctly.

* feat(plugins): phase-0 lifecycle hardening + per-plugin RPC rate limit

Operational-readiness fixes from the completeness audit:

- Re-activation after a failure worked again: a plugin left in 'error' state by
  a load-failure or crash-auto-disable stayed in the running map, so the admin's
  'enable' button was a silent no-op. activate() now replaces a dead entry.
- Per-plugin RPC rate limit at the dispatch boundary: every ctx.* call runs
  synchronously on the host thread, so a plugin in a tight loop could freeze the
  whole instance (and the reap sweep). A token bucket (generous burst) + an
  in-flight cap now throttle a runaway plugin with a retryable HOST_ERROR; a
  legitimate plugin never notices.
- plugin_error_log retention (500 rows/plugin) so a crash-looper can't grow
  trek.db without bound; the crash-timestamp array is trimmed to its window too.
- TREK_PLUGIN_PERMISSIONS=off now logs a loud one-time warning that the OS
  permission jail is disabled.

* feat(plugins): read symmetry + broker — collab/journal/atlas reads, file content, trip create, rates

The plugin API leaned write-heavy: collab and journal could be written but not
read, files listed but not read, and there was no way to create a trip or see
exchange rates. This closes those gaps in the established RPC+gate pattern (zero
architecture risk), and it's what unlocks the importer + finance plugin classes:

- collab reads: ctx.collab.listNotes/listPolls/listMessages under a new
  db:read:collab (membership + Collab addon, like the REST GETs)
- ctx.journal.getEntries(journeyId): a journey's entries, journey-access-checked,
  under the existing db:read:journal
- ctx.atlas.bucketList(): the acting user's bucket list, under db:read:atlas
- ctx.files.getContent(tripId, fileId): a file's bytes as base64 under a NEW
  db:read:files:content grant (reading a passport scan is more sensitive than its
  filename), size-capped at 10MB before it crosses the IPC pipe, trashed files
  refused
- ctx.trips.create(input): a new trip owned by the acting user, gated by the app's
  trip_create right + a bound user — the capability importers need
- ctx.rates.get(base): cached currency exchange rates, tenant-free like weather

Also caps trips.update title/description like the places path, and the plugin-db
guard now bans load_extension (defense-in-depth). SDK, mock-host, i18n (22
locales), consent labels and the wikis are all in lockstep.

* feat(plugins): deeper integration + user-facing activity transparency

Wave 2 of the completeness work — richer extension points, deeper metadata, and
the transparency that makes the broad read grants accountable:

- db:meta now attaches to reservations + accommodations too (not just
  trip/place/day), gated by reservation_edit / day_edit respectively — the
  natural home for an external-id mapping (AirTrail/calendar/booking-import sync)
  without forking the core schema.
- reservation-detail widget slot: a widget can mount on a booking card, scoped to
  the open reservation via reservationId in trek:context (the place-detail /
  day-detail pattern, third instance).
- tableContributor gains the transports + todos views, so a plugin can add
  host-rendered columns/actions there too.
- User activity log: GET /api/plugin-activity + a Settings → Plugins panel showing
  every host-mediated action a plugin took bound to the signed-in user, across all
  plugins, newest first — the user-facing half of the hash-chained audit. This is
  what legitimizes the deliberately broad read grants: not just the admin, the
  person whose data is read can see what was done in their name.
- DX: the local dev server now binds a default acting user, so the canonical
  ctx.trips.getPlaces(tripId) call works locally instead of failing RESOURCE_
  FORBIDDEN; the create scaffold drops the dead manifest routes[] / capabilities.nav
  fields the host ignores.

SDK, i18n (22 locales), consent labels and the wikis are all in lockstep.

* fix(memories): load Immich album photos on Immich v3

Immich v3 removed the `assets` property from AlbumResponseDto, so
`GET /api/albums/:id` no longer carries album contents. TREK read album
photos from that property, which now parses as undefined and degrades to
an empty array — hence "No photos yet" in the Journey gallery picker even
though the album header shows the right count (that count comes from
`GET /api/albums` -> assetCount, which v3 still returns).

Two call sites read the removed property. Besides getAlbumPhotos (the
reported bug), syncAlbumAssets failed silently on v3: it reported
`success: true, added: 0` while syncing nothing.

Fetch album contents via an `albumIds`-filtered `POST /api/search/metadata`
when `assets` is absent, and feature-detect rather than probe a version.
The two paths are not interchangeable: on v2, searchMetadata
unconditionally scopes results to `[self, ...partners]`
(`asset.ownerId = ANY(userIds)`), so an albumIds search against an album
shared by a non-partner returns nothing. v3 added an albumIds branch that
checks AlbumRead and skips that owner filter. v2 also hard-defaults
`visibility` to `timeline`, dropping archived assets. So v2 must keep
reading the album detail body, which this preserves exactly.

`withExif: true` is required on the search path: it has no default and
gates an inner join, so without it Immich omits `exifInfo` entirely and
every photo's city/country goes null.

The existing test mock returned an album detail body *with* `assets` — it
encoded the v2 assumption, which is why this shipped green. It now models
v3 by default, with explicit v2 coverage asserting no search call is made.

Fixes #1492

* feat(plugins): daily AI/notify budgets, runtime scheduler & reliable event redelivery

Per-plugin daily caps on ai.complete/ai.extract and notify.send (defaults
200 / 100, overridable via TREK_PLUGIN_AI_PER_DAY / TREK_PLUGIN_NOTIFY_PER_DAY),
seeded from the capability audit so a mid-day restart resumes the count instead
of resetting it. Surfaced at GET /plugins/:id/budget.

ctx.scheduler (at / in / every / cancel): persistent, userless timers that
survive restarts and fire a scheduled() handler, riding the existing jobs:run
grant so no new consent or admin setup is needed. Backed by
plugin_scheduled_tasks, swept every 30s, capped at 100 tasks/plugin with an 8 KB
payload and a 60s recurring floor; rows are removed on uninstall.

Core events that fire while a subscriber is mid-restart are now held in a
bounded in-memory buffer (200/plugin, 15 min TTL) and replayed once it goes
active again, with the events:subscribe grant and snapshot gating re-evaluated
at replay time so nothing leaks if a grant was revoked while the plugin was down.

* feat(plugins): GDPR data-subject rights — durable per-plugin erasure + export

New hook:user-data grant with two userless lifecycle handlers a plugin can put
on its definition: deleteUserData and exportUserData. Neither carries an acting
user — the plugin only learns the userId and touches its own db — so the grant
reads nothing from core data; it exists purely so a plugin can honour a GDPR
erasure or data-access request.

When a TREK account is deleted (admin or self-service), every installed plugin
holding the grant gets a row in a new durable erasure queue and its
deleteUserData runs on the next sweep, retried until it ACKs — so erasure
survives the plugin being offline or the server restarting. The core deletion
path notifies the runtime through a dependency-free relay (like the event sink),
keeping the auth/admin services decoupled from the plugins layer, and a plugin
bookkeeping error can never fail the account deletion.

Portability is served by GET /api/admin/plugins/user-data/:userId/export, which
fans exportUserData out to the active granted plugins and aggregates what each
holds about the user. Queue rows are purged on uninstall; the grant is labelled
in all 22 locales.

* feat(plugins): atomic ctx.db.tx for consistent multi-write on a plugin's own db

Plugins could already query/exec/migrate their own SQLite file, but a multi-step
write (move an item between tables, decrement one row and increment another) had
no way to be atomic. db.tx([{sql, args?}, …]) runs up to 100 statements in a
single transaction — all commit or all roll back — and reads within the batch see
its own earlier writes, so read-modify-write is safe. Each op is one statement:
a read returns { rows }, a write { changes }. The same guard (no ATTACH/PRAGMA/
RECURSIVE, size + row caps) applies to every statement in the batch.

* fix(memories): filter hidden Immich assets at the source, not just the picker

#1474 has the same root cause as #1492: the Immich v3 migration. On v2,
searchAssetBuilder hard-defaulted metadata search to `timeline` visibility
(`visibility = options.visibility ?? Timeline`), so hidden Live Photo
motion parts could never come back from a search. v3 defaults to any
visibility except `locked`, so they do — which is why the reporter is on
Immich 3.0.1 and why the bug never appeared before.

Ask for `visibility: 'timeline'` explicitly on the search path. That
restores v2 semantics on both versions and stops hidden assets crossing
the wire, which also fixes a pagination wart: a full page half-made of
motion parts previously rendered as a half-empty page, because hasMore
counts the raw page length while the filter shrinks the rendered set.

The client-side filter was display-only, applied in searchPhotos and
getAlbumPhotos — both picker-listing paths. Nothing guarded persistence
or rendering: getOrCreateTrekPhoto stores any id it is handed, pipeAsset
forwards Immich's 400/404 verbatim, and the photo grid is a plain <img>
with no onError. So syncAlbumAssets, which filtered `type === 'IMAGE'`
only, could persist a hidden IMAGE as a permanently broken tile. It now
applies the same guard, extracted as isVisibleAsset().

Albums keep their filter rather than requesting `timeline` visibility:
albums legitimately contain archived assets, and both the v2 album body
and the v3 album search return them.

Does not address tiles already persisted before this — those still render
broken and need a separate fix.

Refs #1474

* docs(memories): correct Immich version boundaries in the hidden-asset comments

Verified against the v1.120.0 → v3.0.0 OpenAPI specs and server source. The
previous comments said "Immich v2 hard-defaulted metadata search to timeline
visibility". That is true only for 1.133–1.144.

- `visibility` was added in 1.133.0. Before that, searchAssetBuilder applied
  `.$if(options.isVisible !== undefined, ...)` with no default, so pre-1.133
  servers returned hidden assets too. #1474 was therefore not purely a v3
  regression.
- Those servers strip the `visibility: 'timeline'` filter rather than
  rejecting it: Immich validates with `whitelist: true` and no
  `forbidNonWhitelisted`. So the request stays valid, the filter is a no-op,
  and isVisibleAsset() is the ONLY guard there. Say so, so it does not get
  removed later as redundant.
- `albumIds` only exists from 1.135.0. Because unknown properties are stripped,
  an albumIds search against an older server would silently drop the album
  filter and return the entire library as the album's contents. Feature
  detection on `assets` (present through 1.144.1, absent on v3) makes that
  unreachable; a version probe with a wrong boundary would not.

Also cite Immich's own enum, which documents AssetVisibility.Hidden as
"Video part of the LivePhotos and MotionPhotos".

Comments only — no behavior change.

* feat(plugins): dashboard trip-card badges + a mock-host driver for plugin tests

Two additions that round out the plugin platform's breadth and its authoring DX.

tripCardProvider hook (hook:trip-card-provider): a plugin returns small declarative
badges for the dashboard trip cards. The dashboard fetches all visible cards in one
call; the host access-checks every tripId for the acting user, bounds each field
(label/value length, enum tone, http/https/mailto-only url), caps the count and drops
any badge for a card that wasn't requested — plugin JS never runs on the dashboard.
Rendered as text chips under the card meta; labelled + gated in all 22 locales.

createMockHost now exposes run(def) — the other half of a plugin unit test. Where the
ctx recorders capture what a plugin read, run() fires its own entry points (route, job,
scheduled, event, plugin-event, deleteUserData, exportUserData, provider hooks) against
the same mock ctx, and host.scheduled surfaces the timers it armed. A handler the plugin
didn't declare throws a clear error instead of a silent no-op.

* feat(plugins): include plugin data + code in backups, applied on restart

A TREK backup archived travel.db + uploads + the encryption key, but each plugin's
own SQLite file — the ONLY copy of the user data it holds — and its installed code
lived in separate trees that were never captured, so a restore left the plugins rows
with no data or code behind them.

createBackup now adds plugins-data/ (each plugin's db + WAL sidecars, so SQLite
recovers a consistent snapshot) and plugins-code/ (skipping dev-links by realpath, so
an author's linked source is never bundled). Restore can't swap those live — the
runtime holds each plugin db open — so it STAGES the extracted trees beside the live
ones and the runtime swaps them in at the next boot, before it opens anything. Same
"applies on restart" model the bundled encryption key already uses: no plugin quiesce,
no swap under open handles, no new admin setup. Older archives without the trees restore
exactly as before.

* fix(plugins): audit — runtime robustness, security & data-lifecycle fixes

Fixes from an adversarial audit of the plugin system, host/runtime side:

Robustness:
- getPluginDataDb recreated a handle a terminal-failure dispose had closed but
  left cached, so a re-enabled plugin's db:own threw on every call — recreate
  when the cached handle is shut.
- ctx.ws.broadcast* now carry _inv, so the host can bind the acting user (the
  capability was silently refused, i.e. dead, without it).
- ctx.events.emit swallows a rejected emit instead of crashing the child into a
  terminal 'error'; an uncaught throw AFTER activation is treated as a crash
  (restart with backoff), not a load failure.
- A crash-respawned child gets the same activation deadline as a first activation
  and the buffered-event queue is cleared on the timeout path, so a hung onLoad
  after a crash can't peg a core and orphan events forever.
- Expired buffered events are pruned by the reaper, not only at flush; the
  scheduler + erasure sweeps scope their LIMIT window to ACTIVE plugins so a
  backlog for inactive plugins can't starve deliverable work.

Security / integrity:
- Unix-domain-socket / named-pipe connects are refused by default in the egress
  guard (a host-local pivot to docker.sock / DB sockets), under the same policy
  as private IPs.
- db.tx refuses transaction-control statements (a raw COMMIT would break its
  atomicity) and caps rows across the WHOLE batch, not per statement.
- plugin_capability_audit is retention-capped per plugin (chain-safe: retained
  rows stay self-verifying), so it can't grow unbounded in the shared db.
- A cap of 0 in TREK_PLUGIN_AI_PER_DAY / _NOTIFY_PER_DAY now disables the broker
  instead of falling back to the default.

GDPR data lifecycle:
- Account deletion now erases host-side per-user plugin tables (config, OAuth
  tokens/state) and enqueues the own-db erasure from the CORE path, so it works
  even when the runtime is disabled or pre-boot; guest deletion does the same.
- uninstall keeps a pending erasure when data is retained (deleteData=false);
  erasure delivery is no longer grant-re-checked (a queued erasure is a duty);
  export flags installed-but-inactive plugins as pending instead of omitting them.

Backup/restore:
- Plugin DBs are WAL-checkpointed before archiving (no torn/stale snapshots).
- Restore applies the staged trees immediately by quiescing the plugins (no
  unbounded gap where a later unrelated restart would revert diverged data);
  the swap is content-level (safe on a volume-mounted root) and preserves
  dev-links; the decompressed-size cap is operator-raisable.

* fix(plugins): audit — hook-output hardening, dashboard slot & mock-host parity

- Map-marker and atlas-layer tones were validated on String(tone) but emitted
  raw, so a non-string tone (an object with a matching toString) slipped through
  and crashed the client that renders it — check the raw value against the enum.
- View-contribution column/action caps are now PER ENTITY, not per view, so a
  plugin's columns no longer vanish from every table row past the first 20; the
  dashboard trip-card badge cap is per card (≥ one on every visible card).
- A reservation-detail widget no longer also renders as a context-free dashboard
  sidebar card (the inline filter was missing that slot).
- mock-host matches the real host: it ignores asUserId on trip reads (bind the
  acting user), throws on a wrong user-scope notify target instead of coercing,
  enforces the scheduler caps, and detects RETURNING as a read in db.tx — so a
  passing author test can't hide a production RESOURCE_FORBIDDEN.

* feat(plugins): full ctx parity in the dev server + fire jobs/events/hooks locally

The trek-plugin dev server injected only ~6 of the ~35 ctx areas, so any plugin
touching ctx.costs/packing/files/notify/ai/settings/scheduler/meta/oauth/db.tx/…
hit a TypeError in local dev while the same code passed mock-host tests and worked
installed. It also could only exercise routes.

Delegate every non-db-own capability to a grant-enforcing mock host (the same one
unit tests use) while keeping the real node:sqlite for db:own and dev-native ws
capture + logging — so the whole surface works in dev with the exact production
permission rules. dev-fixtures.json now takes the createMockHost options shape, so
you can seed the full surface. New GET /__dev/fire/<kind>[/<name>][/<fn>] fires a
job, scheduled timer, event subscription, GDPR handler or provider hook against the
dev ctx, closing the "can't test non-routes locally" gap.

* feat(plugins): wire the photoProvider + calendarSource hooks to real core consumers

Both hooks were declared, typed and documented but NO core code ever invoked them,
so an author could build, mock-test and install a photo or calendar plugin that
silently did nothing. Give each a real consumer that fans out to it, exactly like
the other eight provider hooks:

- GET /api/plugin-photos/search (+ /sources, /item) aggregates photoProvider results
  for the picker — {id, title?, thumbnailUrl, fullUrl, takenAt?}, thumbnail/full URLs
  http/https-only (they become <img src>), per-source count capped, failing source
  skipped.
- GET /api/plugin-calendar?start=&end= aggregates calendarSource events for the
  signed-in user — {id, title, start, end, allDay} ISO, count capped, failing source
  skipped, sensible default window.

Both run with the acting user bound. The SDK interfaces now pass ctx as the last arg
(so a source can reach ctx.settings/oauth/http for its backend), and the wiki marks
them live instead of "reserved — no core consumer".

* feat(plugins): close the create-heavy API asymmetries importers/sync hit

Core services implemented these but plugins had no path to them, so the flagship
importer/sync integrations hit real walls. Added, each reusing the EXISTING grant
(no new consent):

- ctx.trips.removeMember(tripId, userId) — reconcile DEPARTURES, not just additions
  (db:write:members + member_manage). Never removes the owner (that would orphan the
  trip); ownership transfer stays a separate deliberate action.
- ctx.journal.createJourney({title, subtitle?, trip_ids?}) / deleteJourney(journeyId)
  — an importer can now bootstrap the journal it fills with entries and clean it up
  (db:write:journal), instead of only appending to journals a human created first.

Wired end-to-end (envelope → rpc-host → create-rpc-host reusing tripService/
journeyService → both SDK copies → mock-host) and documented. (trips.delete needs its
own destructive permission + consent copy and collab edit/delete + collections.delete
remain — tracked as small follow-ups.)

* feat(plugins): strip emojis from plugin-rendered text so it matches TREK's lucide UI

Plugin authors (especially AI-generated ones) sprinkle emojis into the declarative
text TREK renders in its OWN chrome — hook contributions (badges, columns, warnings,
PDF sections, map-marker/atlas labels, journal rows, place details, trip-card badges,
calendar + photo titles) and notifications — which clashes with TREK's lucide-only icon
language.

A shared stripEmoji() removes emojis (incl. flag/ZWJ/variation-selector sequences) and
tidies the leftover whitespace, applied at the render boundary in every hook-contribution
normalizer and in notify.send — so no matter what a plugin returns, the text TREK draws
stays emoji-free. It does NOT touch a plugin's own sandboxed /ui frame (the author's to
design), and it leaves photo ids verbatim (they round-trip to getById). The validate CLI
warns when a manifest name/description contains emojis, nudging authors to the declarative
`icon` field (a lucide name) instead.

* fix(plugins): harden the restore-apply path — regressions from the backup/dev fix pass

A final audit of the fix pass caught three regressions clustered in the two newest
surfaces; the restore path could both crash the server and destroy data.

- CRITICAL: a restore quiesces plugins via supervisor.shutdownAll() AFTER closeDb(), but
  shutdownAll killed children without first marking them stopped, so each child 'exit'
  took the CRASH path and wrote crash-accounting rows into the now-closed core DB — the
  throw escaped an EventEmitter listener as an uncaughtException and killed the whole
  process mid-restore. shutdownAll now marks every entry stopped and drops it from
  `running` BEFORE the kills (so onExit early-returns), and the onStatus/onLog DB hooks
  are wrapped in try/catch (also covers the stderr→onLog path). This also stops a normal
  shutdown from logging phantom "crashed" rows.
- HIGH: swapContents cleared live entries then MOVED staged ones in, so a crash mid-move
  permanently deleted a plugin's only data copy (staging was already emptied, so a retry
  couldn't restore it). It now COPIES each staged entry over the live one and only deletes
  staging at the very end — `staged` stays the complete source of truth, making the whole
  operation crash-idempotent.
- HIGH: the dev server lost the actingUserId=1 default in the mock-host refactor, so a
  fresh scaffold refused every user-bound capability. Restored.

* fix(plugins): final-audit medium/low findings

- GDPR export flags an active plugin whose export errored/timed out as `pending`
  instead of silently omitting it (collectUserExport now returns a discriminated
  result), so a data-access export never reads complete while missing data.
- Account deletion also enqueues an erasure for plugins UNINSTALLED with retained
  data (an orphan data dir) — a same-id reinstall now honours the deletion instead
  of re-adopting the user's data forever.
- oauth.getToken returns null in a userless context (matching the SDK/mock contract)
  instead of throwing RESOURCE_FORBIDDEN a background caller can't handle.
- Crash-backoff restart is identity-guarded (+ the timer is tracked and cleared like
  the activation timer), so a disable + re-enable during the backoff window can no
  longer respawn a ghost child from the replaced entry.
- db.tx transaction-control guard strips leading comments first, so `/* */COMMIT`
  can't slip past the start-anchored check and break batch atomicity.
- createJournal inherits its cover only from a trip that was actually LINKED
  (access-checked), closing a cross-tenant cover-image read on plugin + REST paths.
- trip-warnings drops a null array element instead of losing ALL of that provider's
  warnings; plugin-activity floors a non-integer ?limit so it can't 500.
- The trek-plugin dev server binds loopback only and refuses cross-site requests to
  its side-effectful /__dev/fire endpoints (it serves real routes + no-auth dev
  actions).

* fix(plugins): clear no-misleading-character-class in the emoji stripper

The character class listed the ZWJ, variation selectors and combining keycap
marks as members, which eslint reads as an accidental combined grapheme and
rejected on CI. Pull the emoji glyphs out into Extended_Pictographic /
Regional_Indicator alternatives so only the joiner/selector code points stay in
the class, with a scoped disable where the rule still can't tell them apart.
While here, reset lastIndex before the /g regex is reused in hasEmoji() so a
second call can't resume mid-string and miss a leading emoji.

* fix(security): trip-scope note-file deletion and guard the LLM base URL

Two reported issues:

- deleteNoteFile only matched on the note id and file id, so a member of trip A
  could delete a file attached to a note in trip B by guessing its id. Thread the
  trip id through the service and controller and scope the delete to it, the way
  every other collab operation already does.

- The LLM extraction clients fetched the user-configured base URL directly, so a
  user could point it at the cloud-metadata endpoint (169.254.169.254) and read
  the echoed error body. Route both clients through a new safeFetchLlm() that
  blocks the link-local/metadata range while still allowing a local or LAN Ollama
  (loopback and private ranges stay reachable), pinned to the resolved IP so a
  hostname can't rebind to the metadata address after the check.

* fix(security): route every LLM client through the SSRF guard

The base-URL SSRF fix covered the openai-compatible and anthropic clients but
missed the native Ollama /api/chat client and the /api/tags + /api/pull model-
management calls, whic…

* fix(plugins): repair plain-HTTP egress and forward the private-egress opt-out

Two pre-existing bugs in the plugin egress guard, found by running a plugin
against a real service end to end.

1. Every plain-HTTP request a plugin made was refused, whatever host it had
   declared. Node pre-normalises `net.connect()` args into an [options, cb]
   array and passes THAT array as the single argument; undici's plain-HTTP
   connector takes this path, its TLS connector does not. classifyConnect read
   `host` off the array, got undefined, and fell back to 'localhost' — so a
   fetch to a declared, public host was rejected with the nonsense message
   "localhost is not in the plugin's declared hosts". It failed closed, so it
   was never a security hole, and it went unnoticed because the only shipped
   egress plugin uses HTTPS. unwrapConnectArgs() unwraps the normalised form
   before anything reads host/path.

2. TREK_PLUGIN_ALLOW_PRIVATE_EGRESS could never have any effect. The guard that
   reads it runs INSIDE the child, whose env is scrubbed to a four-entry
   whitelist that never included it — so a documented setting (wiki/
   Environment-Variables.md) was wired to nothing, and no plugin could reach a
   self-hoster's LAN service no matter what the operator set. Forwarded only
   when set, so the default stays the secure block-private policy.

Regression tests cover the normalised form in both directions: the real host is
now resolved, and an undeclared host, a private IP and a unix socket are all
still refused when passed that way.

* feat(notifications): let a plugin register a notification channel

TREK's four channels (in-app, email, webhook, ntfy) were a closed set:
notificationService.send() dispatched with four copy-pasted `if` blocks and no
provider abstraction, so a fifth channel meant editing eight files by hand. A
plugin could produce a notification via ctx.notify.send(), but never deliver
one.

A plugin now registers a channel with `hooks.notificationChannel` +
`hook:notification-channel` on a plain `type: 'integration'` — not a new manifest
type, so the TREK-Plugins registry schema and both its CI gates are untouched.

Core refactor
- New channel registry (services/notifications/): email/webhook/ntfy become
  ExternalChannel providers wrapping the EXISTING send functions — no delivery
  logic is rewritten, only relocated. In-app deliberately stays out: it writes
  typed rows with scope/target/callbacks, not a rendered title+body, the same
  line shared/ already draws with i18n/externalNotifications.
- The event text is now rendered once per recipient instead of once per channel.
- The channel set is open: NotifChannel becomes a string, the matrix is
  registry-derived, and the UI columns are server-driven. The DB column was
  already bare TEXT and the Zod contract already a string record — only the
  TypeScript and the two UIs were ever closed.

The hook runs USERLESS. Every other hook is user-initiated, so actingUserId falls
out of the request; a notification is host-initiated for an ARBITRARY recipient,
so ctx.settings.get() would return undefined. The host resolves the recipient's
decrypted scope:'user' settings itself and passes them as an argument. That is
what lets a channel plugin be handed someone's push token WITHOUT being handed
the right to read their trips as them.

Enabling the plugin is the opt-in: a plugin channel is not gated on the admin's
`notification_channels` list. A built-in always exists in code and needs an
explicit switch; a plugin channel only exists because an admin enabled that
plugin. (Nothing could write a `plugin:` id into that CSV anyway, and the admin
toggle rebuilt it from three booleans, silently dropping anything else — so
requiring a second opt-in meant the channel could never be turned on at all.)

Also fixed, found while building this:
- Plugin settings keys were unvalidated, so a field named `__proto__` or
  `constructor` resolved off Object.prototype: a REQUIRED field with such a name
  reported as configured for every user who had configured nothing — enough, for
  a channel, to be dispatched to everyone with no credentials. Keys are now
  constrained at install and the config blob is parsed null-prototype, so it is
  impossible even for an already-installed plugin.
- A `select` field's options were cast straight through, so the obvious
  `["1","5"]` form rendered every dropdown entry BLANK (the client reads
  value/label). Now coerced, and malformed options are rejected.

Also adds: operator-supplied egress hosts (a plugin talking to a self-hosted
service can't name the operator's host at publish time, so an admin adds it
post-install and the runtime re-spawns the child with the widened allow-list —
only for a plugin that DECLARED operatorEgress, and only an admin, never a user);
settings-page actions (a "Test connection" button, user-initiated so
ctx.settings.get() returns the clicking user's own value); and a Gotify-shaped
notification-channel template in the SDK.

Verified end to end against a real Gotify container, not just in tests.

* docs(wiki): document the plugin notification-channel surface

Covers the pieces added in the previous commits, in the pages a reader would
actually reach for:

- Plugins.md (the admin-facing page) had none of it: notification channels,
  settings actions, and a full "Allowed hosts" section — including what
  operator-supplied egress deliberately does NOT let anyone do.
- Plugin-Development.md: the notificationChannel hook (and why it is the one hook
  with no acting user), settings-page actions, operatorEgress, and the manifest
  reference rows.
- Plugin-Cookbook.md: a "become a notification channel" recipe and a
  "Test connection button" recipe.
- Plugin-Permissions.md: hook:notification-channel, operatorEgress under the
  outbound section, and settings actions under "not a permission".
- Notifications.md: plugin channels alongside the four built-ins.

* fix(sdk): allow empty egress if and only if operatorEgress is true

* ci: don't run repo-specific workflows on forks

Guard release, publish, wiki-deploy and issue/PR-triage workflows with a
`github.repository` check so they no-op in forks instead of failing or
acting on the fork's own issues, PRs, tags and registries.

Also skip the Docker Scout scan for pull requests from forks: Docker Hub
secrets are never exposed there, so the login step could not succeed.

Tests and lint stay ungated — they need no secrets and are the gate for
incoming fork PRs.

* feat(sdk): add missing methods in mock-host

* fix(airports): rebuild the json file

* fix(airports.json): add small airports too

* fix(public transit): only show public transit option when a trip has actual dates

* fix(plugins): reap a queued erasure only once the plugin's data is gone

The orphan reap deleted every queue row whose plugin had left the registry, but
uninstall(deleteData=false) removes the plugins row while deliberately keeping the
data dir AND the queued erasure so a same-id reinstall can still honour it. The reap
now deletes a row only when the plugin's data dir is actually gone; a deleteData=true
uninstall already clears the rows itself.

* fix(backup): snapshot the core DB and swap restores atomically

createBackup archived travel.db via the archiver's lazy live-file read, so a WAL
auto-checkpoint firing mid-stream could write a torn database into the zip. It now
VACUUM INTOs a point-in-time snapshot and archives that, the same guarantee plugin
DBs already get. restoreFromZip swapped the DB by unlink-then-copy, which on an
interrupted restore could leave no valid travel.db; it now copies to a temp file and
renames it into place (atomic), dropping the stale -wal/-shm sidecars first.

* fix(deploy): Recreate strategy for the SQLite volume, pin the root compose image

The Helm Deployment had no strategy, so the default RollingUpdate would start a second
pod holding the same ReadWriteOnce PVC before the old one exits — a Multi-Attach
deadlock or two writers on one SQLite file. Default to Recreate (overridable for
ReadWriteMany). The root docker-compose.yml pinned trek:dev, a tag no workflow builds,
so a clone-and-up at the release tag ran a stale image; pin it to :latest like the README.

* fix(security): re-validate LLM endpoint fetch redirects per hop (GHSA-fmq9)

safeFetchLlm left undici's default redirect:'follow', so a configured LLM
endpoint could 302 to http://169.254.169.254/ and reach cloud-metadata
credentials — the DNS pin does not cover an IP-literal redirect hop, since
net.connect skips the pinned lookup for a literal IP. Follow redirects
manually now, re-resolving/re-checking/re-pinning each hop (allowing LAN/
localhost as before). Also block the Alibaba metadata IPs directly.

* fix(plugins): throttle the plugin log channel to prevent host-thread DoS

The per-plugin RpcRateLimiter only guarded the ctx.* (req) channel; ctx.log.*,
stdout/stderr and unknown evt topics reached a synchronous INSERT+prune on the
host thread unthrottled, so a while(true) ctx.log.error(...) loop could freeze
the instance. Route every plugin-driven log path through a per-plugin log token
bucket; excess lines are dropped with a summary line on resume.

* fix(plugin-sdk): serve dev /ui frame at /ui/index.html so relative assets resolve (#1526)

The dev server embedded the plugin UI as <iframe src="/ui"> (no trailing
slash), so a multi-file build's relative asset URLs (./assets/x.js from Vite
base:'./') resolved against the origin root -> /assets/x.js -> 404, even though
the files are served at /ui/assets/*. The real host loads the frame at
/plugin-frame/<id>/index.html where the same relative URLs resolve correctly, so
dev now matches it by loading /ui/index.html (and also serves /ui/ as index.html).

* i18n: improve Russian translations (#1539)

* v3.4.0 (#1527)

* fix(plugins): unknown column

* fix(mcp): reuse MCP sessions instead of creating one per tool call

The /mcp CORS layer never set exposedHeaders, so Access-Control-Expose-Headers
was absent and browser-context MCP clients (Claude Desktop connectors,
Claude.ai, MCP Inspector) could not read Mcp-Session-Id off the initialize
response. Unable to echo it back, every request looked like a fresh initialize:
one McpServer and one session per tool call, until the per-user cap returned a
429 and the integration died until the container was restarted.

The idle sweep was not at fault — it expires on lastActivity with a 1h default,
so sessions born seconds apart are nowhere near expiry, hence 'cleaned 0'.

- expose Mcp-Session-Id, MCP-Protocol-Version and WWW-Authenticate
- evict a user's least-recently-active session at the cap rather than
  refusing the request, so a client that cannot persist its session id (or a
  proxy that strips the header) can never wedge the server
- close the McpServer/transport orphaned by every session-less non-initialize
  POST, which was leaked: never mapped, never swept, never closed
- return the cap error as JSON-RPC so clients surface the real reason
- warn on session-less POSTs to make a header-stripping proxy diagnosable

* chore(deps): declare @modelcontextprotocol/sdk ^1.29.0

Matches the version already resolved in the lockfile; no dependency-tree change.

* docs(mcp): add the reverse proxy specs for MCP

* fix(plugins): stop the row ⋯ menu being clipped by the sidebar

The menu was an in-flow `absolute` div, and its ancestor (PageSidebar) is
`overflow-hidden` — which clips absolutely-positioned descendants regardless
of z-index. With enough plugins installed a row sits low enough that its menu
runs past the sidebar's bottom edge and gets chopped, taking Delete with it,
so the plugin could no longer be uninstalled from the UI.

Portal the menu to <body> and position it `fixed` against the ⋯ button,
flipping upward when the bottom is tight and re-anchoring on scroll/resize.
A fixed child of <body> has no overflow ancestor, so nothing can clip it.

Closes #1523

* feat(plugins): surface author-signature status and add a scoped re-trust override

TREK has always verified an author's Ed25519 signature and TOFU-pinned the key on
first install, but none of it was ever shown: a successfully-installed UNSIGNED
plugin looked identical to a signed one, and a signature-refused update left the
plugin quietly pinned at its old version with the reason dying in a toast.

Give the four refusal conditions machine-readable codes (SIGNATURE_MISSING /
_INCOMPLETE / _KEY_CHANGED / _INVALID), persist a refusal on the plugin row so the
admin list keeps showing it, and badge Signed/Unsigned in the list and in Discover.

Only SIGNATURE_KEY_CHANGED is overridable — an author can legitimately rotate a key;
a signature that does not verify means the bytes are not what the author signed, and
there is no story where waving that through is right. The override re-pins and
updates in ONE call (POST :id/retrust): a re-pin that waited for a follow-up /update
would leave the plugin pinned to a key no install had ever been verified against if
that second call never came. The artifact must still verify under the new key, so a
re-trust moves the pin from one verified key to another.

assertRetrustable re-derives the condition server-side, so the UI hiding the button
is a convenience, not the control, and it echoes back the full key the admin was
shown so a re-key since the dialog rendered is refused. The rotation is written to
the admin audit log with both fingerprints — after an incident, "which key did we
move from, and to what?" is the question a single key cannot answer.

* fix(plugins): let a plugin's frame reach the hosts an admin added for it

The frame's connect-src was built from the manifest's http:outbound grants alone, but
the child's egress guard is the UNION of those and the hosts an admin added after
install for an operatorEgress plugin (a self-hosted Gotify, an ntfy — hosts the author
cannot know in advance). So such a plugin WITH A UI could call the operator's host from
its server and was CSP-blocked in its own iframe.

Match the frame to the child. The admin consented to these hosts at install and the
child already reaches them, so this widens no trust boundary that isn't already crossed.
Both sources stay validated on the way in, and the interpolation filter is unchanged.

* fix(plugins): report a rejected events.emit on the plugin's own log stream

The host can reject an emit (an undeclared event name, a rate limit). The rejection must
not escape — a detached rejection crashes the child and terminally disables the plugin
over one bad emit — but swallowing it silently left an author with no way to discover
that `emits` was missing from their manifest. Surface it as a warning instead.

* feat(plugin-sdk): expose the raw request body for webhook signature checks

A webhook author must run their HMAC over the exact bytes the sender signed. `body` is
the PARSED value, and re-serializing it will not reproduce those bytes — key order,
whitespace and unicode escaping all differ — so the signature never matches. Document
`rawBodyBase64`, which the host already sets on auth:false routes.

* fix(plugin-sdk): refuse to overwrite a released artifact, and keep the packed zip

A released artifact is IMMUTABLE: the registry pins its sha256, so overwriting the bytes
of a release already in the registry breaks the checksum for everyone who installed that
version — they can no longer install or update it. The old code blanket-caught every
`gh release create` failure (auth, network, a bad repo) and turned it into a --clobber
upload. Probe for the release explicitly and refuse unless --force.

Also stop deleting plugin.zip on the way out. It is the exact bytes the release and the
entry's sha256 pin were computed from; a re-pack on another machine or SDK version can
differ (CRLF, walk order), so anyone re-running `entry`/`sign` afterwards must hash THAT
file, not a rebuild.

* fix(plugin-sdk): don't fail submit when the fork already has an upstream remote

`gh repo clone` of a fork may already have wired `upstream`, in which case a bare
`remote add` exits non-zero and took the whole submit down. Set it either way.

* fix(plugin-sdk): make the dev server behave like the host

Three ways `dev` lied to an author about how their plugin would run in production:

- ctx.db: one try/catch wrapped both the node:sqlite probe AND opening the database, so
  an mkdir/permission failure silently degraded to the in-memory stub — which swallows
  every write while reporting success. A db:own plugin "worked" in dev and persisted
  nothing. Probe separately: fail loudly on a real error, degrade only on old Node, and
  say plainly that the stub discards writes.
- notificationChannel: the host fires it with no acting user and hands the recipient's
  decrypted settings in as a separate `config` argument — send(msg, config, ctx) /
  test(config, ctx). Firing it like an ordinary hook passed `ctx` where `config` belongs,
  so a channel plugin read its settings off ctx and was broken in production.
- /preview pinned tripId 42 while the scaffold seeds trip 1, so the widget's first
  trek:invoke hit assertMember(42) and 500'd. Preview against a trip that exists.

* fix(plugin-sdk): reject unknown flags, wire up create's new flags, and add --help

`parse()` accepts any --x, so a flag a command does not read was silently dropped:
`create --template notification-channel` cheerfully scaffolded a blank plugin. Silently
ignoring an author's explicit instruction is worse than refusing it, so unknown flags are
now an error, and create actually forwards --template/--egress/--required-addons.

A bare --permissions used to split the string "true" into a permission literally named
`true`; listFlag() now treats a valueless flag as absent.

Since an unknown flag is now fatal, `--help` has to exist: it is intercepted before the
flag check and prints usage on stdout with exit 0.

* chore(plugins): drop three unused eslint-disable directives

The no-console rule is not enabled for these files, so the directives were dead and
eslint reports them as unused.

* fix(plugin-sdk): bring preflight back in step with the registry's gates

preflight exists to tell an author what TREK-Plugins' CI will say before they open the
PR. The registry now verifies author signatures, and preflight didn't — so it drifted
into the one failure mode it must never have: a false green. An author trusts a green.

- Verify the signature against the artifact bytes. preflight already downloads them for
  the sha256 check, so this costs one call. Without it, signing with the wrong key (or
  re-packing after signing) sails through and is caught at review.
- Check the signature SHAPE (checkSignatureShape): a key with no signed version, a
  signature with no key, a malformed key or signature. TREK refuses to install a
  half-signed entry, so such an entry is dead on arrival.
- Default apiVersion to 1 before comparing. It is OPTIONAL in the manifest — install/
  manifest.ts and `entry` both default it — so a manifest that legally omits it was
  failing preflight with "manifest apiVersion undefined != entry 1" while the registry
  passed it. A false RED, which teaches authors to ignore the tool.
- Check requiredAddons/pluginDependencies parity, and operatorEgress without an
  http:outbound grant.

The verifier is a port of the host's install/verify-signature.ts (the registry has its
own port); sign.ts's verifyArtifact only understands the bare key/signature pair the SDK
itself emits and cannot judge a minisign key. A test pins all three to the same verdicts.

* feat(plugins): enforce compatibility range

* chore: bump sdk version

* test(e2e): repair the trip-creation specs

create-trip and trip-planner have been failing for a while — long enough for
three separate UI changes to drift past them, which nothing caught because CI
runs vitest only and never invokes Playwright.

Each failure was masking the next:

- The release-notice modal greets a freshly seeded user and its backdrop
  swallowed the click on .add-trip-card. Added a shared dismissSystemNotices()
  helper (the X only shows on the notice's last page, so it has to page through
  first).
- .modal-backdrop no longer exists — the class was namespaced to
  .trek-modal-backdrop so content blockers stop hiding it.
- input[type=text].first() is no longer the Title field: the cover-image search
  inputs now sit above it, so the specs were typing the trip name into the photo
  search box and creating an untitled trip that never matched getByText(title).

* fix(planner): gate drag & drop on pointer type, not viewport width (#1432)

The 3.2.1 fix disabled drag on "mobile", but nothing in the client has ever
detected touch — "mobile" was inferred from viewport width, at four independent
breakpoints. A tablet is a coarse-pointer device at a *desktop* width, so an
iPad (820-1366px) fell on the wrong side of all four, which is why iPhone was
fixed and iPad was not:

- useTripPlanner's isMobile (<768px) is what disarmed `draggable`, so on iPad
  rows stayed draggable and a scroll swipe became an HTML5 drag.
- TripPlannerPage hardcoded isMobile={false} on the desktop PlacesSidebar, so
  its drop handlers and the drop-to-import overlay could never be disabled —
  that overlay is the reported symptom.
- The arrow-button reorder fallback was revealed only below 767px, leaving iPad
  with no drag *and* no fallback.
- touchDragPolyfill loaded drag-drop-touch at >=1024px, synthesising drags from
  touchmove — on a landscape iPad that re-armed the very gesture hijack 3.2.1
  removed.

Adds useIsTouch() ((pointer: coarse)) as a signal separate from isMobile: layout
stays width-driven, so the iPad keeps the desktop two-pane planner, while every
drag affordance is gated on isMobile || isTouch. The reorder arrows now show on
coarse pointers, and the polyfill loads only on hybrid laptops
((pointer: fine) and (any-pointer: coarse)), which also removes the #1440
phantom-dblclick map zoom on tablets.

Guarded by unit tests plus an e2e spec on real WebKit in an iPad Pro 11 context
— the engine matters, since every browser on iPadOS is WebKit underneath, which
is why the reporter hit this in all three they tried.

* test(planner): guard the day-plan reorder arrows against phantom clicks

The hover rule that reveals the reorder arrows had been dead since the
TypeScript migration: it targeted `.place-row:hover .reorder-btns`, and neither
class exists — the component renders `.reorder-buttons` inside an unclassed row.
The buttons sat at opacity:0 on desktop with nothing to reveal them.

That was not merely invisible. opacity:0 still hit-tests, so every itinerary row
and day note carried an invisible, fully clickable target that silently
reordered the trip:

  opacity: "0", visibility: "visible", pointerEvents: "auto"
  elementFromPoint(centre) -> BUTTON, inside .reorder-buttons

The repair — pointer-events: none while hidden, plus a working
.dp-row:hover/:focus-within reveal (focus-within so the buttons are also
reachable by keyboard, which the file's JS-hover pattern cannot do) — lives in
index.css and DayPlanSidebar.tsx. Both files also carry the #1432 drag gating,
so they went with that commit rather than being split mid-file; this commit is
the regression guard for them.

E2E rather than unit, because jsdom does not evaluate :hover. Against the old
CSS it fails on exactly the right assertion: "hidden arrows must not swallow
clicks" — expected false, received true.

* fix(pdf): keep the header gap on day-header overflow pages (#1531)

The repeated <thead> day header (#1471) carried no gap before the first
card on overflow pages: the 12px sat in .day-body's block-start padding,
which a fragmented box only paints on its first fragment. Move it to the
thead cell so it repeats with the header; the day's first page renders
pixel-identically.

* fix(budget): keep "no one paid yet" when editing an expense (#1533)

The ExpenseModal payer initializer fell back to the current user whenever the
edited item had no payer, so reopening an expense saved with "no one paid yet"
silently reselected "You" — and re-saving then recorded the current user as the
payer, corrupting the balances. An absent payer on an existing expense is a
deliberate value, so only a brand-new expense defaults to me.

* feat(sdk): support for plugin icons

* fix(planner): keep the places filter applied and visible across tab switches

The category and all/unplanned/tracks filters lived twice: a local copy in
the sidebar driving the checkboxes and the list, and a page-level copy
driving the map markers, synced only when a checkbox was clicked. Switching
planner tabs unmounts the sidebar, so the local copy reset to "All
Categories" while the markers stayed filtered — and the only way out was
toggling any category on and off again (#1541).

The filter now lives once in the trip store, next to selectedDayId: it
survives the Plan tab unmounting (and the mobile places sheet closing),
keeps both sidebar instances in agreement, and resets when another trip
loads.

* feat(airtrail): import connecting flights as one multi-leg booking

AirTrail flights always imported as separate single-leg reservations, so a
layover could not be expressed and the connection country ended up counted
as visited in Atlas (#1535).

The import picker now detects connection chains among the listed flights —
each leg departing from the airport the previous one landed at, onward
within 24 hours, and never back to the origin (an out-and-back is a return,
not a connection) — and offers to import each chain as one flight with
layover stops, on by default. The joined booking keeps per-leg airline,
flight number, times and seat in metadata.legs, files every leg on its own
trip day, and mirrors the first/last leg flat, exactly like the manual
multi-leg form. With the connection stored as a stop endpoint, the existing
Atlas role filter excludes the layover country on its own.

AirTrail has no multi-leg flight a joined booking could round-trip to, so
it imports detached from live sync, with every source flight id recorded in
metadata.airtrail_ids — the picker and the server-side dedupe both treat
those legs as imported, per leg, even across trip members. The server
re-validates each requested chain and falls back to individual imports when
it does not actually connect.

* fix(airtrail): stop syncing a booking once it grows extra stops

A linked flight that becomes multi-leg locally no longer matches the single
AirTrail flight it was imported from: pushing would rewrite that flight to
span the whole route, and the next pull would flatten the layover chain
back to a plain from/to. Both sync directions now detach the link instead —
the same state a joined import starts in, surfaced by the existing "Not
synced" badge.

TransportModal also carries metadata.airtrail_ids through re-saves (like it
already does for transit itineraries and day positions), so editing a
joined booking cannot cost it its import dedupe and get its legs re-offered
in the picker.

* fix(planner): default a new accommodation to checking out the next day

The hotel picker pre-filled "Apply to days" with the same day for check-in
and check-out — a stay that ends the day it begins. New accommodations now
default to the following day for check-out; the last trip day keeps the
same-day range, and editing still seeds from the stored range.

* docs(wiki): document the AirTrail import and connection joining

* fix(plugins): fold resolvePluginIcon into PluginIcon

pluginIcon.ts and PluginIcon.tsx resolve to the same file on the
case-insensitive filesystems dev checkouts commonly sit on (Windows,
macOS) — tsc sees both casings of one module and fails with TS1149 in
every importer. Keep the resolver and the component in one module.

* feat(sdk): add update verification

* chore: remove test files

* fix(sdk) rework the sdk helpers and DX/UX

* fix(sdk) harden dev environment

* fix(budget): add back the multi payer selection

* fix(map): stop MapLibre mouse rotation from reversing near mid-screen

Since MapLibre 5's camera rewrite, the right-button rotate handler flips
its sign whenever the cursor sits above a mid-screen line it derives by
re-projecting the map center. That line drifts with the bearing by a
fraction of a pixel, so inside the 100px band around the screen center a
steady horizontal drag lands alternately above and below it — every
processed movement reverses the previous one and the camera ping-pongs in
place instead of rotating (#1545). A real hand crossing the line mid-drag
flips the rotation direction outright. maplibre-gl 4.x rotated from plain
horizontal movement and had none of this.

Passing aroundCenter: false opts the handler out of the around-center
mode and restores the 4.x/mapbox-gl behaviour: horizontal drag rotates,
vertical drag pitches, in one continuous motion. Applied to all three GL
map builds (planner, journey, settings preview); mapbox-gl keeps its
options untouched.

* fix(budget): settle in the trip's real currency, not always EUR

The settlement route read `currency` off the row returned by canAccessTrip,
whose SELECT never included the column. A cast hid the mistake, so trip.currency
was always undefined and the settlement was told every trip is in EUR.

Balances are netted in the trip currency and converted to the display currency
once. With the trip mislabelled EUR, expenses in the trip's own currency still
cancelled out, but an expense booked in a foreign currency was divided by its
frozen rate into trip-currency units, then converted again as if those were
euros — inflating balances by the EUR/trip rate (~27x for a RUB trip with a USD
expense, #1543). MCP was unaffected: it reads the currency with its own SELECT.

Select the currency in canAccessTrip so the read is real. The settlement maths
was correct all along; it was simply being lied to about the base currency.

Fixes #1543

* feat(trips): let users set the trip currency, and rebase the budget when it changes

The trip currency is the base every expense and settle-up is netted against, but
the only picker for it lived in the legacy Budget addon panel — so on the Costs
panel a trip was stuck with whatever it was created as. Add the field to the trip
form, on create and on edit, gated on trip_edit. The REST and MCP write paths
already accepted `currency`; only the form was missing.

Changing it is not a rename, though: an expense's frozen `exchange_rate` is
"units of its currency per 1 trip currency", and `currency = NULL` means "the
trip's own", so both are relative to the outgoing base. Swapping it out from
under them redenominates the implicit rows (9 000 RUB becoming 9 000 EUR) and
leaves the frozen rates pointing at a currency the trip no longer uses — the same
mismatch that inflated #1543.

So rebaseTripCurrency() runs first, while the old currency is still on the row:
it pins the implicit rows to the outgoing currency and re-freezes every rate
against the incoming one, for expenses and settle-up transfers alike. No stored
amount is rewritten — each keeps the figure the user typed, in the currency they
typed it in, and its real-world value survives the switch.

Also covers the #1543 data as a settlement regression test.

* feat(budget): give settle-up payments their own currency

A transfer settling a shared bill can be made in any currency — paying a rouble
debt in euros is normal — and the server has stored `currency` + a frozen
`exchange_rate` on every transfer since #1445, re-freezing it on edit. The UI
just never let anyone choose one, so a payment silently inherited whatever the
viewer's display currency happened to be.

Add the picker to the payment modal, mirroring the expense modal, and reopen an
existing payment in the currency it was actually recorded in. The ledger row now
shows a foreign payment as `$30.00 -> 27,00 EUR` like a foreign expense does,
instead of stamping the display currency's symbol onto the raw stored number.

The Settle buttons on the suggested flows keep sending the display currency:
those amounts are computed in it.

* feat(settings): make the display currency optional, falling back to the trip's

Costs already resolved `default_currency || trip.currency || 'EUR'`, but the
setting could never actually be empty: the store seeded 'USD', so a user who had
never touched it silently had every trip converted into dollars, and the picker
offered no way to unset it.

Seed it empty and lead the picker with a "Trip currency" option, so an unset
preference means "show each trip in its own currency" instead of forcing them all
through one code. An explicit empty value persists and beats the admin-set
instance default — it is a deliberate choice, not an absence.

This also brings the public share's fallback to life: the share payload has
resolved sharer's currency -> trip currency since #1361, but the trip-currency
branch was unreachable while every owner had a currency forced on them.

Plugins are handed `formats.currency` as a concrete code, so PluginFrame now
resolves the same chain rather than passing an empty string through the bridge.

* docs(wiki): explain the three currencies and how they relate

Trip currency, expense currency and display currency answer three different
questions, and nothing said so: the trip currency wasn't documented at all (it
had no picker until now), and Budget-Tracking conflated the other two while
still claiming 47 currencies and a display currency that always came from
Settings.

Add a Currencies page as the one place they're defined together — the trip
currency as the accounting base, the expense currency as the receipt with its
rate frozen at entry, the display currency as presentation-only — plus what
happens when a trip's currency changes, which currency a public share renders
in, and what belongs to the Costs addon versus the trip itself.

Rewrite Budget-Tracking's currency section against it, document the currency
field in Creating-a-Trip and the display currency in Display-Settings (which
never mentioned it), and note the sharer-or-trip fallback in Public-Share-Links.

* feat(help): serve the in-app wiki from disk instead of fetching GitHub

The /help pages fetched their markdown from raw.githubusercontent.com at
runtime, so a self-hosted install was served docs from main rather than the
version it was actually running, and help was unusable without network access.
The wiki/ directory was in the repo the whole time; the bundled-snapshot
fallback the code reached for was gitignored and never populated by any build
step, so it was dead code.

Read wiki/ straight from disk instead. server/{src,dist}/services both sit
three levels under the repo root, so a single __dirname anchor resolves in dev,
a built source install, vitest and Docker with no copy or build step. GitHub is
kept strictly as a fallback for when the directory cannot be resolved, decided
once at load by probing for _Sidebar.md: a page missing from a present wiki is
a genuine 404, since falling back per-file would reintroduce the version skew
this removes.

Ship wiki/ in the image: .dockerignore excluded it outright, so the COPY alone
would have produced an image with no wiki, and the GitHub fallback would have
masked that at runtime. Add a real path-containment check on assets now that
the path becomes a filesystem read rather than a URL, and document
TREK_WIKI_DIR as an off-by-default escape hatch across the deployment configs.

* fix(plugins): serve frame assets root-relative and cross-origin loadable

res.sendFile(absolutePath) resolves against the rewritten req.url under
the Nest ExpressAdapter and 404s spuriously (files-download already
works around the same trap), which broke every plugin frame document.
And helmet's CORP: same-origin made the browser drop the opaque-origin
frame's own script/style subresources, so a multi-file plugin client
could never boot. Serve root-relative and mark frame responses
cross-origin — sandbox + per-plugin CSP stay the isolation boundary.

* feat(plugins): let a plugin ship its own settings page

A plugin that declares capabilities.settingsUi: true gets its
client/settings.html framed as a card under Settings -> Plugins — same
opaque-origin sandbox and postMessage bridge as its widget, sized via
trek:resize. Hosts that predate the flag strip it at install, so old
instances keep working and simply don't show the card.

* feat(map): open maps framed on their places (builds on #1393) (#1556)

* fix(map): fit MapLibre routes reliably

* fix(map): default planner map to world view

* fix(map): only await route geometry when a route is actually pending

The fit armed a pending route-refit slot on every fitKey change, even with no
route drawn, and only ever cleared it when a route arrived. So a route toggled
on much later — after the user had panned elsewhere — was mistaken for the
awaited geometry and yanked the camera back, and only on the first toggle.

Arm the slot only when a route is already on screen: updateRouteForDay lays down
straight lines in the same batch as the fit and upgrades them to real geometry a
moment later, so an empty route at fit time means none is coming.

* fix(collections): open the empty collection map on the world view

A collection with no mappable places centred on Paris, the same hardcoded
default this branch removes everywhere else.

* feat(map): open the map framed on its places

A trip in Japan opened on the world view at 0,0 and only then animated a fitBounds
flight across the planet — the hardcoded default was the map's answer to a question
its own places already answer.

Each renderer now derives its opening camera from the places it receives, at
construction: MapView for Leaflet, MapViewGL for MapLibre and Mapbox (whose zoom
runs one level below Leaflet's, measuring against a 512px world tile rather than
256px). Doing it at construction is what confines it to load — the map is built
once, and by then the trip's places are in hand. Nothing recomputes it afterwards,
so the camera stays where the user leaves it, and the opening fit stands down
rather than overruling the gentler zoom a lone place opens at.

A trip with no coordinates still falls back to the world view. Collections and the
public shared-trip page frame themselves the same way.

* refactor(settings): drop the default map centre and zoom

Nothing reads them now that every map frames itself on its own places, and a
home-city default was the wrong answer for the next trip on the other side of the
world. The style preview keeps a fixed location of its own: it needs a city to show
label density and 3D buildings, which open ocean cannot.

* fix(map): frame the map the way each renderer can actually draw

Two defects the unit tests missed and running the app exposed.

MapLibre and Mapbox opened on Null Island at zoom 2 regardless of the places: the
effect that mirrors an external centre prop onto the camera also ran on mount, so
it jumped straight to the default nobody passed and threw away the camera the map
had just been built with. It now only responds to actual changes, which is what it
was for. Leaflet was unaffected — its controller already guarded on the centre
changing.

A trip spanning Sydney, Reykjavik and Santiago lost Sydney's marker entirely. The
narrowest arc containing all three crosses the antimeridian, and framing there is
only sound on a renderer that repeats the world: MapLibre and Mapbox draw a marker
on whichever copy is nearest the camera, Leaflet draws one world and puts the
marker at its absolute position — off-screen. Leaflet now spans the long way round,
as L.latLngBounds would. The test that should have caught this wrapped the x-offset
in its own projection helper, quietly assuming behaviour Leaflet does not have; it
now models each renderer's real wrapping.

---------

Co-authored-by: Azalea <noreply@aza.moe>

* feat(mcp): expose public transit planning tools (#1558)

* feat(mcp): add public transit planning tools

* refactor(transit): reuse local time conversion

* fix(mcp): harden transit journey validation

* refactor(transit): centralize itinerary processing

* fix(mcp): return the transit itineraries the provider actually offers

search_transit_routes validated each itinerary leg's mode against
SCHEDULED_TRANSIT_MODES, but that constant is the request-side filter
whitelist — the modes a caller may ask for — not the modes MOTIS can return.
Its default TRANSIT mode expands to TRAM,FERRY,AIRPLANE,BUS,COACH,RAIL,ODM,
RIDE_SHARING,FUNICULAR,AERIAL_LIFT,OTHER, and street legs can be BIKE/CAR/
RENTAL. Any itinerary carrying one of those failed the parse and was dropped
by the flatMap, so the tool reported fewer routes than exist — or none at all.
Against the live provider, Trondheim → Ålesund returns 5 itineraries and 3 of
them contain an AIRPLANE leg, so the tool silently discarded them; the web app
shows all 5, because it treats mode as a free string and renders anything
non-WALK as a transit leg.

Accept any mode token on a leg and keep the existing "at least one non-WALK
leg" rule as the real gate, which restores parity with the web app. Everything
downstream keeps its mode !== 'WALK' semantics, so a journey created over MCP
is identical to one created in the app.

Dropping an itinerary is still possible when provider data fails the remaining
consistency rules, but it is indistinguishable from "no routes exist" — so
search_transit_routes now reports a `dropped` count alongside the results.

---------

Co-authored-by: Uzini <43294422+Uziniii@users.noreply.github.com>

* fix: show map poi search controls on mobile (#1555)

* fix(pdf): use the trip's actual currency instead of hardcoded EUR (#1519)

* fix(pdf): use the trip's actual currency instead of hardcoded EUR

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(i18n): drop hardcoded currency text from pdf.costLabel across locales

Remove redundant EUR/currency references from pdf.costLabel translations
across 21 locales now that the PDF export correctly renders amounts
with their actual trip currency via formatMoney().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(pdf): drop hardcoded euro-shaped icon from place price chip

svgEuro rendered a fixed € glyph next to the price chip regardless of
the trip's actual currency, undermining the currency fix. Swap for a
currency-neutral coin icon.

---------

Co-authored-by: Nguyen Trong Binh <nguytb15@VN1N07HO1CD1015.local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* fix(trips): pin place prices when the trip currency changes

A place price with no currency of its own means "the trip's own currency" —
that is how the PDF export and the place chips read it since #1519. But
rebaseTripCurrency() only pinned budget_items and budget_settlements, so
switching a trip's currency silently redenominated every implicit place
price: a €15 museum on a trip moved to JPY started reading as ¥15. Same
class of mismatch as #1543, on the places surface.

Pin priced, currency-less places to the outgoing currency inside the same
transaction. No amount is rewritten — the figure the user typed keeps its
real-world value, it just stops being ambiguous about which unit it is in.
Bump updated_at as well: it doubles as the optimistic-concurrency token
(#1135), so a client holding the pre-switch row can no longer write the
pin away.

Document place prices in the wiki's currency model, and drop the now-stale
"in EUR" from the PDF export's estimated-cost stat.

* fix(budget): re-split expenses when a member is removed

* fix(plugins): derive plugin req.user.isAdmin from role, not is_admin

* chore(test): make jsdom Storage work under Node's native Web Storage globals

* fix: recompute vacay calendar when week start changes (#1554)

* fix(websocket): handle socket 'error' events to prevent crash on malformed frames (#1584)

* fix(packing): apply templates into the active list tab (#1581)

* Fix packing list readability on mobile

* Localize packing quantity label in overflow menu

* fix(atlas): stream boundary GeoJSON instead of caching parsed bundles (#1585)

* fix(atlas): stream boundary GeoJSON instead of caching parsed bundles

* fix(atlas): scan admin1 features in one pass to avoid O(n^2) build

createFeatureSplitter re-scanned each partial Feature from the start on
every gunzip chunk, so a large feature (Canada ~5.5MB, ~340 chunks) made
the one-time admin1 store build O(n^2). That pushed the streaming build
(~4.2s) past the 15s test timeout under CI coverage/fork contention and
added the same latency to the first live region request.

Carry scan state (position, brace depth, string state) across chunks so
each character is examined once. Output is byte-identical (3228 features,
197 countries) and peak build memory is unchanged (~192MB RSS under a hard
512MB cap); build time drops to ~1.7s.

* fix(i18n): add packing.quantity to all locales

The packing.quantity key existed only in en (Qty) and de (Menge), so
i18n:parity:strict and the client parity test failed for the other 20
locales. Add the key everywhere; en/de are unchanged.

---------

Co-authored-by: jubnl <jgunther021@gmail.com>

* fix(trips): keep accommodations on their dates when the trip range shifts (#1288)

The v3.1.3 fix re-anchored dated bookings after a trip date change but
explicitly excluded hotels: day_accommodations has no absolute date columns,
so stays remained glued to positionally re-dated day rows and shifted with
the range. updateTrip now snapshots day dates before generateDays and a new
resyncAccommodationDays re-anchors each stay (and its linked hotel
reservation, restamping its stale reservation_time) to the days holding its
pre-change dates; out-of-range stays stay glued so whole-trip moves still
shift together. Unlinked dated hotels resync like any booking, and the
date-change block is wrapped in a transaction.

Changing the start date now also asks how plans should follow via a new
date_shift_mode field ('keep_bookings' default / 'shift_all', which reuses
the reorder/insert restamp path to glue everything), exposed in the trip
edit modal (all 22 locales), the shared contract, and the MCP update_trip
tool. Clients no longer show stale state: the initiator reloads
reservations + accommodations after saving, collaborators refetch on a
date-changing trip:updated, and reconnect hydration nudges the planner's
accommodations too.

* fix(extract): retry with json_object and surface AI import failures (#1546)

OpenAI-compatible providers that only support json_object (DeepSeek,
Mistral, some vLLM/llama.cpp) reject the json_schema response_format
with a 400, and the resulting error was swallowed silently: not logged
server-side and never rendered by the background-task widget, leaving
only a generic "no reservations" message.

- Retry the chat/completions request once with response_format
  json_object when the json_schema attempt returns 400 (non-NuExtract
  only); the system prompt already dictates the output shape
- Log swallowed llm-parse errors with an [llm-parse] tag so failures
  show up in server logs
- Render task warnings under the empty-preview note in the background
  tasks widget so the actual provider error reaches the user

* fix(costs): keep ticket item amounts visible on narrow screens (#1568)

* chore: update repo url

* chore: update repo url

* chore: update repo url

* chore: update repo url

* fix(security): block IPv6 transition addresses (NAT64/6to4/Teredo) in SSRF guard

An attacker-controlled DNS record pointing at a NAT64 (64:ff9b::/96),
6to4 (2002::/16), or Teredo (2001:0000::/32) address that embeds a
private IPv4 (e.g. 64:ff9b::a9fe:a9fe = 169.254.169.254) bypassed the
SSRF guard: none of the guard functions recognised these ranges, so on a
host that routes the transition prefix the connection reached the
embedded private target (cloud metadata / internal SSRF).

Add a shared embeddedTransitionIpv4() detector and re-apply each guard's
own blocklist to the extracted IPv4 in isAlwaysBlocked, isPrivateNetwork,
isLinkLocal (ssrfGuard.ts) and isBlockedIp (egress-policy.ts). A
transition address to a public IPv4 stays allowed so legitimate
IPv6-only egress is unaffected.

egress-policy.ts keeps the detector inline to preserve its dependency-free
contract for the isolated plugin subprocess.

* chore: Add star history

* Revert "chore: Add star history"

This reverts commit f9d5f75837.

* fix(map): draw transit routes even without other places on the day

The reservation overlays hide any transport whose from/to endpoints
project closer than a per-type pixel threshold (200px for transit) to
declutter tiny no-op straight connectors. A transit journey, though,
draws its real rail/bus alignment rather than a straight endpoint line,
so on a zoomed-out day — one with no other places to tighten the map
onto — its stations fall under the threshold and the whole route
vanishes.

Exempt a transit booking that carries real per-leg geometry from the
proximity gate in both renderers (Leaflet + MapLibre); a geometry-less
transit keeps the straight-arc declutter.

* fix(atlas): resolve region AND country by coordinates against the bundled polygons

Rebased onto dev's streaming atlas index (#1576): region resolution now
resolves a place's lat/lng directly against the same bundled admin1
polygons the client renders — offline, deterministic, and guaranteed to
match a bundle feature — rather than trusting Nominatim's address level,
which can name a subdivision the bundle doesn't carry (Barcelona's ES-B
province vs the bundle's ES-CT autonomous community) and never highlight.
Country resolution moves to the same coordinates-first order, so a place
stored "..., San Francisco, CA" no longer resolves to Canada.

admin1 is held as per-country GeoJSON text (never parsed whole, #1576),
so a country's regions are flattened to the compact Float64Array form and
cached on first use — only visited countries pay the parse. The stale GB
constituent-country rescue is removed, and a one-time migration clears the
re-derivable place_regions cache so every place re-resolves under the new
logic.

Closes #1547

* fix(client): convert mixed-currency day/trip cost totals instead of mislabeling raw sums (#1561)

Day headers, the plan sidebar footer and the PDF day/cover totals summed
raw place prices across currencies and labeled the result with a single
currency — a $2,730.27 hotel on a NOK trip read as "2730 NOK".

Totals now convert every amount into a base currency via the existing
frankfurter rates (sidebar: the user's display currency, falling back to
the trip's; PDF: the trip currency, resolved once before rendering so the
document is consistent), marked with "≈" when a conversion happened. When
a rate is unavailable (offline, blocked egress, unknown code) they fall
back to an honest per-currency breakdown ("2 500 kr + $2,730.27") instead
of folding foreign amounts into a mislabeled number. All-same-currency
trips make no FX request, so offline PDF export keeps working.

Also swaps the place inspector's hardcoded € chip icon for a neutral one
and formats the price via formatMoney in the place's own currency.

Note: day-header totals move from "50 EUR" to Intl formatting ("50 €").

* feat(atlas): let a visited region be hidden, cascading to the country when none remain (server)

Countries already have a hide/tombstone mechanism (hidden_countries, #1490):
a zero-count derived country can be dismissed and stays gone across reloads.
Regions had no equivalent — unmarkRegionVisited only ever deleted a
manually-marked visited_regions row, a no-op for the common case of a region
derived fresh from place_regions on every request, so there was no way to
dismiss one at all (place-derived or otherwise).

Adds the region-level counterpart:
- New hidden_regions table (user_id, region_code, country_code), mirroring
  hidden_countries.
- getVisitedRegions() filters its result through it, the same way getStats()
  already filters through getHiddenCountries().
- unmarkRegionVisited() now tombstones unconditionally (not just for a
  manually-marked region — a region with a real place attached, e.g. one
  misassigned by a border-simplification gap, is exactly the case this
  exists for) and derives the country code from the region code's
  "<country>-<rest>" prefix when there's no visited_regions row to read it
  from.
- Cascade: after hiding a region, if the country has no other visible region
  left (checked against place_regions + visited_regions, minus hidden_regions),
  the country is hidden too via the existing unmarkCountryVisited.
- markRegionVisited() clears both tombstones on re-mark, so a region (and its
  cascade-hidden country) can come back.

Note the cascade only has a visible effect on a country with no real place
attached to it — getStats' places-derived country entries are never
suppressed by hidden_countries (#1490's deliberate "reappears with a real
place" rule), so hiding every region of a country that DOES have real places
leaves the country visible, by the same existing design. Test coverage
reflects this.

Server-side only — client wiring (a way to trigger this from the map) is a
separate commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(atlas): let a visited region be hidden, cascading to the country when none remain (client)

Client wiring for the server-side hide-region feature (previous commit).

Clicking a visited region on the map used to open the hide/unmark
confirmation only when it was manually marked (visitedRegions[...].manuallyMarked);
a region derived from real place data instead opened the country-detail
view, with no way to dismiss it at all. Now any visited region offers the
same "Remove this region from your visited list?" confirmation regardless of
how it was derived — country details remain reachable via the country
search/sidebar, which was never gated on this in the first place.

The confirm handler's optimistic country-removal check dropped its
`&& r.manuallyMarked` filter on the remaining-regions count, matching the
server's unconditional cascade — but keeps the existing "only when the
country has zero real places/trips" guard, since a country backed by real
data is never actually hidden server-side (#1490) and removing it from the
UI early would just flash and reappear on the next reload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(settings): make fresh-instance unit defaults internally consistent

A brand-new instance — no admin-set defaults, no saved value — rendered
temperatures in Fahrenheit and times on the 12-hour clock while distances
defaulted to metric, a mix that matches no locale. The store also seeded
'fahrenheit'/'12h' while DisplaySettingsTab's fallback said 'celsius', so the
two paths disagreed about the intended default in the first place.

Default to one system — celsius / metric / 24h — matching the already-metric
distance_unit. The unit defaults now live in a single exported DEFAULT_SETTINGS
that both the store and DisplaySettingsTab's fallback read, so they can't drift
apart again. Admin-set user defaults (getAdminUserDefaults) and any value a user
has already saved still take precedence; only the code-level default changed.

* feat(i18n): complete Catalan (ca) translation

Adds Catalan as a supported language — the full shared/src/i18n/ca locale
(all domain files plus the notification texts), registered in
SUPPORTED_LANGUAGES and the client locale loader. Rebuilt onto current dev so
the locale is at full key parity with en (i18n:parity:strict clean).

* fix(map): stop real road-route fetches from dying under StrictMode

useTransportRoutes cached its AbortController in a ref that was
created once and aborted on unmount. React StrictMode's dev-only
mount->cleanup->remount cycle ran that abort during the *simulated*
cleanup, permanently poisoning the controller before the real mount's
fetch ever started — every road-routed booking (car/bus/taxi/bicycle)
silently fell back to a straight line in local dev, while production
builds (no StrictMode double-invoke) worked fine.

Fix: create a fresh AbortController per effect run instead of a
ref-cached singleton, and synchronously un-mark a job as "attempted"
in that same run's cleanup if it didn't settle before the cleanup
fired — so a StrictMode remount (or any other pre-completion
cancellation) retries instead of being skipped forever. Verified
against the real OSRM endpoint in a running dev server: all four
road-routed legs on a live trip now resolve with real road geometry
instead of straight lines.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(map): add reservation route-visibility util

Extracts the route-visibility filter that was hand-copied, identically,
in both MapView.tsx and MapViewGL.tsx into one pure, unit-tested
function: a reservation's route shows on the map when it's a transit
booking with the day-route toggle on, or its id is in the caller's
visible-ids set. isRoutableReservation (>= 2 endpoints) is exported
separately since callers besides the map filter need the same check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(map): add per-trip connections-visibility resolution util

Pure storage/resolution logic for a trip's booking-route visibility
preference, keyed trek:visible-connections:<tripId>. Two modes:
'only' (nothing shown except the listed ids — today's existing
behavior; a legacy bare-array localStorage value parses as this mode
for backward compatibility) and 'all-except' (everything routable
shown except the listed ids). A trip with no stored preference falls
back to the account-wide default (all or nothing) without writing
anything, so flipping the account setting later never silently
overrides a trip with an explicit per-trip choice already recorded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(settings): add always-show-booking-routes account setting

New map_always_show_routes account setting, defaulting to off, i18n'd
across all 22 supported locales. Lives in Display > Travel & map,
directly under Booking route labels — its closest sibling — using
that section's immediate-save On/Off pattern rather than a separate
toggle+Save flow, since it's a booking-display preference, not a map
render-config option.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(map): per-trip booking-route visibility with account default and bulk toggle

Lets a user see a booking's route on the map without manually
toggling it per item, two ways:

- The account setting from the previous commit sets the default for
  any trip that's never had its routes touched before.
- A new bulk "show all / hide all" button in the day-plan toolbar
  flips a trip explicitly between the two connectionsVisibility modes,
  independent of the per-item toggle (which still edits whichever
  mode's id list is active, in both directions, including while the
  account default is on).

useTripPlanner.ts resolves a trip's effective visible-connection ids
from connectionsVisibility.ts + the account setting + the trip's
routable reservations, and exposes it through the same
visibleConnections/toggleConnection contract MapView, MapViewGL and
DayPlanSidebar already had, plus allConnectionsShown/
toggleAllConnections for the new bulk control. MapView/MapViewGL
consume it via the shared reservationRoutes util instead of each
carrying their own copy of the filter.

The bulk toggle's tooltip gets its own map.showAllConnections/
hideAllConnections i18n keys (all 22 locales) distinct from the
per-item toggle's text, and matches the per-item toggle's active
(solid blue) styling rather than the toolbar's generic hover tint.

Manually verified end-to-end in a running dev server: the account
default seeding an untouched trip, the bulk toggle flipping a trip
between all-shown/all-hidden, and a single per-leg override while the
trip is in all-shown mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Update documentation for booking visibility change

* fix(i18n): add Catalan keys for the booking-route toggle + fix the language-count test

#1483 added map.showAllConnections/hideAllConnections and
settings.alwaysShowRoutes/Hint to every locale that existed when it was
written; Catalan (#1418) landed just after and was missing them, which
broke i18n parity. Also bumps the SUPPORTED_LANGUAGES test to 23 entries
and asserts the Catalan one — the ca addition left that assertion stale.

* test(e2e): add a reproducible documentation screenshot run

The wiki screenshots had drifted three months and two releases behind the UI:
39 of 46 assets came from a single commit in April, and pages such as
Budget-Tracking carry a disclaimer that their own images are out of date.
Retaking them by hand is what created that drift in the first place.

This adds a `screenshots` Playwright project that captures them from a dev
build instead:

- seed.ts populates a demo trip over the REST API — deliberately in JPY, so the
  3.4.0 currency work (per-trip currency, frozen FX rates, foreign-currency
  settle-up) is actually visible rather than hidden behind EUR.
- shot.ts settles the page before capturing (fonts, images, transitions) so
  captures don't catch skeleton loaders, and rewrites /auth/app-config to clear
  dev_mode — the E2E backend runs NODE_ENV=development, which would otherwise
  put a "Dev: Notifications" tab no real deployment has into every admin shot.
- promote.mjs downscales 2880px captures to 1600px on the way into
  wiki/assets/, which is 70% smaller for detail the wiki never renders.

Tabs are located by their visible label, so a rename like Budget → Costs fails
the run loudly instead of quietly capturing the wrong panel.

npm run shots && npm run shots:promote

* docs(wiki): retake screenshots against 3.4.0

Regenerated with `npm run shots` from the dev build. Replaces 21 assets and
adds 13 surfaces that had no screenshot at all.

Notable corrections:

- Collections.md referenced assets/Collections.png, which was never committed —
  the only broken image in the wiki. It now exists.
- The Budget panel became Costs in 3.3.0 (#1464); every budget image still
  showed the old label.
- The trip-create dialog gained a Currency field in 3.4.0, absent from the old
  TripCreate.png.
- Settings grew a Plugins tab and the admin sidebar a Plugins entry; neither
  appeared in the old sidebar shots.
- Weather now renders in Celsius. A fresh instance defaults to Fahrenheit while
  distance defaults to metric, so the seed pins both units — the mismatched
  defaults are a separate bug, not fixed here.

Koffi is installed from the community registry rather than dev-linked, because
dev-link and sideload both stamp a badge on the plugin card that an ordinary
install never shows.

Total 3.6 MB for 34 images, against 26 MB for the 46 assets already in the
directory.

No wiki page text is touched here — pages still point at the old filenames
where those were kept, and the newly added images are not referenced yet.

* fix(help): make wiki links and anchors work in the in-app reader

The wiki pages are written in GitHub-wiki style, and 455 of their links across
81 pages use the bare relative form — `[Currencies](Currencies)` — against only
114 `[[..]]` links. processMarkdown only rewrote the latter, so in-app every one
of those 455 fell through to HelpPage's external-link branch, opened a new tab
and 404'd. Since 6c87bf2f serves the wiki from disk, that is the primary way
users read these docs.

Rewriting them in the renderer fixes every page at once and keeps the sources
GitHub-compatible, so contributors can keep writing either form and neither
target breaks. Rewriting the 81 files instead would have fixed today's pages and
left the trap open for tomorrow's.

Also:

- Code is protected from the rewrite. Plugin-Development.md documents
  `actions[key](ctx)`, which reads as a markdown link and would otherwise be
  corrupted into `actions[key](/help/ctx)` inside a verbatim snippet.
- `[[Page#anchor|Slug]]` no longer renders its anchor as visible link text.
- Headings carry GitHub-compatible ids, so the 22 in-page `](#anchor)` links
  scroll instead of doing nothing.

* docs(wiki): correct settings, map and addon docs against 3.4.0

The wiki described settings that no longer exist and pointed at UI labels that
had been renamed. Each correction below was checked against the code.

- The Settings tab is labelled **General**, not "Display"
  (shared/src/i18n/en/settings.ts:6). Every "Settings → Display" path was wrong.
  The *display currency* setting keeps its name — only the tab was renamed.
- Colour mode is on the **Appearance** tab, not Display.
- The "Route calculation" setting does not exist: no hits for route_calc /
  routeCalc / auto_route / calculateRoutes anywhere in client or server.
- Default map centre and zoom were removed in 3.4.0 (0f4766e1). Replaced with
  what actually happens now, written from client/src/utils/mapViewport.ts:
  every map frames itself on its own places, world view when a trip has no
  coordinates.
- A third map provider, MapLibre GL / OpenFreeMap, was undocumented. It needs no
  access token, which is the reason a reader would choose it over Mapbox.
- Budget-Tracking.md said the feature is called Costs everywhere and then told
  the reader to open the "Budget" tab. The screenshot disclaimer is gone too —
  the images now show Costs.
- The admin tab table was missing Plugins.
- The `packing` addon is seeded as "Lists", not "Packing list management".
- Install docs pinned `mauriceboe/TREK:3.0.15` as the exact-release example,
  four minor versions stale.

* docs(wiki): document passkeys, calendar feeds, appearance, plugins and help

Five shipped features had no user documentation at all:

- **Passkeys** — WebAuthn enrolment and sign-in, admin policy, RP ID/origins.
  Previously mentioned only in passing in Environment-Variables.
- **Calendar Feeds** — the subscribable per-trip and per-user ICS feeds. Note
  Day-Plans-and-Notes documents only the one-off .ics *export*; the two are
  cross-referenced so it is clear which is which.
- **Appearance Settings** — the whole tab, including the custom accent colour
  and its contrast check.
- **Admin: Plugins** — installing from the registry, the pre-install permission
  review, egress hosts, and what Reviewed/Signed/Unsigned actually guarantee.
- **In-App Help** — that the wiki ships in the image and is served from disk
  since 3.4.0, with the GitHub fallback and TREK_WIKI_DIR.

One deliberate deviation from the brief: the Appearance settings are documented
as account-level, not per-device. They persist to /api/settings on the user
account with nothing in localStorage, so the dashboard widget picker's
desktop/mobile split still changes both from either device.

All five are listed in _Sidebar.md.

* docs(wiki): add screenshots for the remaining surfaces

Second capture pass, bringing the run to 42 screenshots. Adds the surfaces that
need more than a navigation to reach: collection and journey detail, MCP access,
two-factor setup, the settle-up payment dialog, and the trip file manager.

Two fixes to the harness itself, both of which had produced misleading images:

- The admin captures showed a "Dev: Notifications" tab that only exists when the
  server runs NODE_ENV=development. The run now clears dev_mode in the
  /auth/app-config response; switching the server to production instead would
  have enabled HSTS and broken the run over http://localhost.
- The settle-up capture clicked "Settle up", which does not open a view — it
  records the transfers. It zeroed every balance and photographed "Everyone's
  square", and because the specs share one database it poisoned Costs.png in the
  same run. Screenshot specs must not mutate state; it now captures the
  "Add payment" dialog instead.

Koffi is installed from the community registry rather than dev-linked, since
dev-link and sideload both badge the plugin card in a way no ordinary install
does.

* test(e2e): capture the detail pages and dialogs

Adds the second wave of screenshot specs (collection/journey detail, MCP access,
2FA, settle-up dialog, files) and enables the mcp, documents and collab addons
in the seed so their surfaces render instead of 404ing.

* docs(wiki): point plugin authors at the agent skill and the registry

Plugin-Development jumped straight into scaffolding without saying that two
supporting resources exist. TREK-Plugins was referenced only in passing far down
the page, and Plugin-Skill — an agent skill that teaches Claude Code and other
SKILL.md-compatible agents to build and publish a plugin — was not mentioned
anywhere in the wiki.

Both are called out up front, with a note that neither is required: the registry
only matters once you want other instances to find your plugin.

* test(e2e): capture the four collab surfaces separately

One Collab.png illustrated chat, notes, polls and the What's Next widget, so at
most one of those four wiki pages showed the feature it described. Each now has
its own capture.

Two things the collab seed needed:

- The conversation is posted by three different people. Every collab write is
  attributed to the acting user, and a single-voice chat log would misrepresent
  the feature outright.
- Each member therefore gets its OWN request context, created with an explicit
  `storageState: undefined`. Without that, newContext inherits the project's
  storageState — the admin's trek_session cookie — and extractToken reads the
  cookie BEFORE the Authorization header (server/src/middleware/auth.ts:9). The
  posts still return 200; they are just all recorded as the admin. That is
  exactly what happened on the first attempt, and the DB was the only place it
  showed.

The collab view is not tabbed — CollabPanel renders all panels at once — so the
captures target cards by seeded content rather than clicking tabs or matching
headings, whose DOM text is 'Notes'/'Polls' while CSS renders them uppercase.

* docs(wiki): show every screenshot on the page it belongs to

Finishes the wiring the screenshot commits deliberately left out.

- Embeds the 14 images that were committed but displayed nowhere: the Costs
  panel and settle-up dialog, the trip planner, transports, documents,
  collection and journey detail, the notifications inbox, the Offline and
  Account settings tabs, appearance, admin user defaults, registration and
  password reset.
- Splits the four collab pages onto their own images. Chat, Notes, Polls and
  What's Next each showed the same Collab.png until now; the overview shot moves
  to Real-Time-Collaboration, which had no image at all.
- Day-Plans-and-Notes pointed at TripPlaner.png — one 'n'. It now uses the
  correctly spelled file, and the misspelled one is deleted since nothing else
  referenced it.
- Removes 45 dead '<!-- TODO: screenshot -->' markers whose screenshot had long
  since been added. 9 remain, each on a page that genuinely still lacks the
  image it asks for — so the marker means something again and the gap is
  greppable, which is how this drifted unnoticed for three months.

Every asset in wiki/assets/ is now referenced by a page, and every image
reference resolves to a file.

* docs(wiki): add the four collab screenshots

Chat now shows a real three-person conversation rather than one voice talking to
itself, and the poll shows three separate votes across two options.

44 images, 4.6 MB total.

* test(help): point the asset test at the correctly spelled screenshot

The integration test hard-coded assets/TripPlaner.png — one 'n' — so deleting
the misspelled file broke it. It was the only thing keeping that filename
alive.

* docs(wiki): regenerate the screenshots on top of the rebased dev

ba3733da changed the fresh-instance defaults to celsius/metric/24h. Every
capture showing a clock — chat timestamps, bookings, day plans — and the General
settings tab itself were still on the 12-hour clock, so 39 of 44 images needed a
new run. Regenerating them is one command, which is the point.

The seed keeps pinning the units explicitly: it now matches the new defaults, but
stating them keeps the captures reproducible if a default moves again.

* docs(wiki): regenerate screenshots after rebasing onto dev

dev added a Catalan translation, an always-show-booking-routes account setting
and a bulk route toggle in the day-plan toolbar since the last run — all visible
on captures we ship. 15 of 44 images changed.

Also resolves the Map-Features conflict from 41d12e89: upstream's new bulk-options
section is kept, with the two 'Settings → Display' paths corrected to 'General'.
The tab is labelled General (shared/src/i18n/en/settings.ts:6), and upstream's own
i18n key for that section is settings.general.travelMap.

* chore: only allow manual trigger for the build&push

---------

Co-authored-by: Maurice <61554723+mauriceboe@users.noreply.github.com>
Co-authored-by: Dieter Blomme <dieterblomme@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: sld272 <zjrdmczh@outlook.com>
Co-authored-by: Nguyen Trong Binh <nguytb15@VN1N07HO1CD1015.local>
Co-authored-by: Maurice <mauriceboe@icloud.com>
Co-authored-by: Pavel Zolotarevskiy <code@fxgn.dev>
Co-authored-by: Azalea <noreply@aza.moe>
Co-authored-by: Uzini <43294422+Uziniii@users.noreply.github.com>
Co-authored-by: Daniel <drmoreno271@gmail.com>
Co-authored-by: trongbinhnguyen <43725147+trongbinh15@users.noreply.github.com>
Co-authored-by: Konstantinos Thermos <info@subdee.org>
Co-authored-by: Konstantinos Thermos <subdee@users.noreply.github.com>
Co-authored-by: Lucas Español <lucas.espanol@tutanota.com>
Co-authored-by: fbnlrz <frlrnzn@gmail.com>
2026-07-18 22:13:52 +02:00
jubnl f9c992ec93 bump sdk 2026-07-11 22:33:55 +02:00
github-actions[bot] afea106aed chore: bump version to 3.3.0 [skip ci] 2026-07-11 20:31:37 +00:00
github-actions[bot] 24e3c5891a chore: bump version to 3.2.2 [skip ci] 2026-07-11 20:29:08 +00:00
Maurice 19064b3917 3.3.0 (#1520)
* 3.3.0 (#1472)

* feat(plugins): grow the frame bridge — fill pages, confirm, openExternal, live context

- page/trip-page hosts pass fill: the frame pins to 100% height and ignores
  trek:resize, so a kit plugin's auto height report no longer collapses a full
  page into a floating island with dead space below (widgets keep self-sizing)
- trek:confirm renders the native ConfirmDialog host-side (the sandbox has no
  allow-modals) and answers trek:confirm:result; one at a time
- trek:openExternal opens validated http(s) URLs in a noopener tab — the
  sandbox has no allow-popups, so plugins simply couldn't link out before
- trek:notify accepts an optional duration, clamped to 1.5-15s
- context gains dir (rtl/ltr) and is re-pushed when locale or format settings
  change, not just on appearance mutations
- core events for the trip in view are forwarded as trek:event — names only,
  never payloads, mirroring the server-side events surface

* fix(plugins): move trip warnings out of the content area

The warning pills overlaid the top of every planner tab at full width, sitting
on the map and its toolbar. Now a warning from a plugin that owns a trip-page
tab renders as a compact chip in the navbar centre (click jumps to the tab; the
navbar centre is free on trip pages), and everything else floats above the
content at the bottom instead. Mobile keeps all warnings in the bottom overlay
since the desktop navbar isn't there. The trip tab's frame also opts into the
new fill mode.

* feat(plugin-sdk): 1.4.0 — motion library + new bridge helpers in the kit

Mirrors the host's animation vocabulary 1:1 into TREK_UI_CSS (menu/popover/
modal/backdrop/toast enters, drawer variant under 640px, page-enter, stagger,
skeleton shimmer, chart reveals) including the reduced-motion degrade to a
gentle fade. window.trek grows confirm(), openExternal(), onEvent() and a
notify duration, and applyContext now stamps lang/dir on the document so RTL
hosts get RTL plugin UIs.

* feat(plugins): surface registry download counts in browse

The registry now aggregates GitHub release download counts per plugin
(TREK-Plugins#18) as an entry-level downloadCount. Project it through
browse/detail and show it as a compact stat on the browse cards and in the
detail meta grid. Counts are raw asset downloads (updates and CI included),
so the UI says downloads, not installs.

* docs(plugins): document the grown bridge surface and motion classes

* fix(plugins): harden the new bridge paths

Review pass over the bridge additions:

- keep the unstable useToast() object out of the effect deps (ref instead) —
  it re-created the effect on every parent render, and with the new live
  repost that meant a trek:context flood into the frame
- reset loads/height/confirm state and key the iframe when a host swaps
  pluginId in place (tab bar, /plugins/:id) — the new plugin's document was
  refused as a 'navigated' frame and every kit promise hung
- confirm dialogs always lead with the host-controlled plugin name so a
  plugin can't dress its dialog up as a TREK system prompt; answer/refuse
  moved out of setState updaters (StrictMode ran them twice)
- Number.isFinite on the notify duration (NaN parked a sticky toast)
- don't forward other plugins' namespaced broadcasts as trek:event; a
  plugin's own plugin:{id}:* broadcasts now reach its frame though
- teach the SDK dev preview the confirm/openExternal contract so
  trek.confirm() resolves in /preview
- 999,950 downloads formats as 1M, not 1000k

* fix(planner): let plugin warning chips grow wider before truncating

The nav-centre chip capped at 340px, so a longer warning (e.g. the TREK x
Japan weather prompt) was ellipsised almost immediately. Scale it with the
viewport up to 520px so most messages read in full while still yielding on
narrow desktops.

* feat(plugins): sort the plugin browser by download count

Discover now honours the sort dropdown (it was always alphabetical) and adds
a 'Most downloads' option that ranks the registry by downloadCount. The sort
keys are scoped per tab — updates-first stays with Installed, most-downloads
with Discover — and snap back to name when the tab can't offer them.

* feat(plugin-sdk): auto-upgrade native <select> to a host-styled dropdown

A sandboxed plugin can't reach the host's components, and a native <select>
draws its popup from the OS — so plugin dropdowns never matched TREK. The design
kit now enhances every <select> into a keyboard-accessible listbox that uses the
kit tokens, keeping the real element as the value/form source (it still fires
change). Authors write a plain <select> and get the host look for free; opt a
field out with data-trek-native. validate warns when a plugin ships a <select>
without inlining the kit.

* feat(plugins): add issue url link

* feat(plugins): reservations write + cross-trip reads

- db:write:reservations -> reservations.create/update/delete, gated exactly like
  the REST/MCP path (reservation_edit + trip membership, acting user host-bound,
  no impersonation) and delegating to ReservationsService so the accommodation,
  budget-sync, booking-notification and reservation:* broadcasts match the web
  app 1:1 — a booking/flight/import plugin can finally write a reservation
- trips.listMine / reservations.listMine: enumerate every trip and booking the
  acting user can access (membership baked into listTrips, never a raw
  cross-tenant SELECT) — dashboards/aggregates were impossible before
- audit: derive auditability from METHOD_PERMISSION so a new capability method
  can't be added un-audited by omission
- typed ctx.reservations.* / ctx.trips.listMine, perm label (en/de), wiki

* feat(plugins): read scopes for journal, atlas, vacay and day notes

- db:read:journal / db:read:atlas / db:read:vacay expose the acting user's OWN
  journals / visited countries+regions / vacation plan across all their trips
  (user-scoped like costs.listMine, each gated on its addon being enabled),
  reusing the addon's existing readers
- db:read:daynotes -> daynotes.list(tripId, dayId), trip-scoped and
  membership-checked like the other trip reads
- typed ctx.journal / atlas / vacay / daynotes, perm labels (en/de), wiki,
  audit resource labels, tests

* feat(plugins): day notes write scope

- db:write:daynotes -> daynotes.create/update/delete, gated under the app's
  'day_edit' permission (like days) with the day verified to belong to the trip;
  reuses dayNoteService and broadcasts the same dayNote:* events so open
  sessions update live
- typed ctx.daynotes.create/update/delete, perm label (en/de), wiki, tests

* feat(plugins): run declared background jobs on a schedule

- plugins already declared jobs {id, schedule} but the cron was never wired. The
  host now schedules them: host-entry reports each job's schedule, the supervisor
  starts the jobs (node-cron) when the plugin goes active and stops them on
  kill/deactivate so nothing leaks
- opt-in via a new jobs:run permission — scheduled work runs with NO acting user
  (its trip reads stay refused; it can only use ctx.db and declared egress), so
  background execution is a distinct, admin-granted capability. Invalid crons are
  skipped and a throwing job can't break the host
- extracted a small, unit-tested scheduler (plugin-jobs.ts); perm label (en/de),
  wiki, tests

* feat(plugins): read scope for saved-place collections

- db:read:collections -> collections.listMine() / collections.get(id): the acting
  user's own collections (user-scoped, gated on the Collections addon), reusing
  collectionsService
- typed ctx.collections, perm label (en/de), wiki, audit resource labels, tests

* fix(plugins): translate new permission labels to all locales + cover the new wiring

- add the 8 new admin.plugins.perm.* labels (reservations/day-notes writes, the
  journal/atlas/vacay/day-notes/collections reads and jobs:run) to the remaining
  20 locales so the strict i18n key-parity test passes again
- cover the create-rpc-host reservation / day-note / cross-trip / addon-read deps
  (the real side-effect wiring the mocked rpc-host tests don't exercise) so the
  src/nest 80% branch-coverage gate holds

* feat(plugins): dev-link — hot-reload a local plugin against real data

Answers a plugin developer's ask: today you either get `trek-plugin-sdk dev`
(fast hot-reload but MOCK/fixture data) or the full build->pack->upload->activate
cycle (real data, no watcher). Neither gives "local dir + hot-reload + real data".

- POST /admin/plugins/link registers a plugin from a LOCAL built directory by
  symlinking it into the plugins volume — the loader already forks the resolved
  real path, so ZERO loader change — and registering it INACTIVE as `local:link`.
  Validates the manifest + refuses native binaries exactly like a sideload.
- POST /admin/plugins/:id/reload re-forks a linked plugin via the existing
  deactivate->activate primitive (same grants, no re-consent unless the manifest
  widened perms). A best-effort fs.watch auto-reloads on rebuild.
- It runs through the UNCHANGED capability RPC host: real, membership-gated data,
  acting user host-bound, no impersonation — code origin never touches the gate.
- Gated behind TREK_PLUGINS_DEV_LINK on top of admin + kill-switch, because a
  linked plugin bypasses the install-time signature model and, under `npm run
  dev`, the OS jail is off. Off by default; never reachable in production.
- discovery follows a symlinked <root>/<id>; uninstall/link never delete the
  author's source (link-safe removal for POSIX symlinks and Windows junctions).

* docs(plugins): document the dev-link real-data hot-reload workflow

Adds a "Test against a real instance's data (dev-link)" subsection next to the
mock-data SDK preview: TREK_PLUGINS_DEV_LINK, POST /link with a local built dir,
activate + consent, hot-reload via the file-watch / POST /:id/reload / Restart,
and the dev-only security caveats.

* feat(plugins): dev-link admin UI

- surface devLink (TREK_PLUGINS_DEV_LINK) in GET /admin/plugins so the panel shows
  the link form only where dev-link is enabled
- AdminPluginsPanel: a "Link a local plugin" form (path -> POST /link), a Dev-Link
  badge for source_repo=local:link, and adminApi.pluginLink/pluginReload
- fix: the plugin menu treated any non-local:upload source_repo as a GitHub repo,
  so a dev-linked plugin rendered github.com/local:link links — exclude local:link
- labels for the 6 new dev-link UI strings across all 22 locales

* docs(plugins): document the dev-link admin UI

The dev-link section showed only the curl call — surface the Admin → Plugins
"Link a local plugin" field (the primary path) and the Dev-link badge, with curl
kept as the scripting alternative.

* feat(plugins): enrich core events with { entity, entityId }

Subscribed plugins now learn WHICH entity changed, not just the event name — a
reservation/place/day/... id derived host-side from an explicit per-family
whitelist. Threaded through the six event hops WITHOUT touching actingUserId: the
handler still runs with no user, so the id is not dereferenceable (a trip read is
still refused; the id says what to react to, not what it contains). A non-entity id
can never surface — budget:member-paid-updated yields the itemId, never the userId
— and bulk/reorder/sub-entity payloads carry no id. The mapper is pure, synchronous
and never throws into the core broadcast. No new permission (reuses
events:subscribe); backend-only.

* feat(plugins): packing write scope with #858 privacy-scoped broadcasts

- db:write:packing -> packing.create/update/delete, gated under the app's
  'packing_edit' permission (like the REST path) with the host-bound acting user
  as owner; reuses packingService
- replicates the packing privacy model 1:1 (the controller/service helpers aren't
  exported): create/delete fan out to the item's viewers only (owner + recipients,
  or the whole room for a Common item); update runs the four public<->private
  transitions, dropping a freshly-privatized item from the room BEFORE re-adding it
  owner-only so it never leaks. A stale write is BAD_PARAMS with no broadcast
- typed ctx.packing.create/update/delete, perm label (22 locales), wiki, tests
  (rpc-host gating + the four transitions + owner-scoped delete)

* feat(plugins): tableContributor hook — host-rendered view columns/actions (backend)

The registry backend for plugin-contributed columns/actions in the native planner
views (the tabular-reservations use case), mirroring placeDetailProvider:
- hook:table-contributor + the tableContributor hook (getContributions(view,
  tripId, ctx) -> TableContribution[]), double-gated (implement + grant) like the
  other provider hooks
- GET /api/view-contributions/:view/:tripId — view whitelist + membership gate +
  per-provider timeout/fail-safe, plus the hardening the older provider hooks lack:
  every field is String-coerced + length-capped, kind/tone/target enum-whitelisted,
  per-provider counts capped (<=20 columns / <=10 actions), and a column url must be
  http/https/mailto (a javascript:/data: url is click-XSS into the native DOM)
- typed pluginsApi.viewContributions + the ViewContribution union, perm label
  (22 locales), wiki, hardening tests

* feat(plugins): render tableContributor columns/actions in the reservations view

The frontend for the tableContributor hook: a reusable PluginContributions layer
(usePluginViewContributions + PluginColumns/PluginActions) that renders the
host-normalized column/action leaves NATIVELY — a column is text/badge/link, an
action is a button that calls the plugin route or opens its sandboxed frame in a
modal (plugin markup only ever runs inside the opaque-origin iframe). Wired into the
reservations cards (both ReservationCard and TransitJourneyCard) as a strictly-
additive footer keyed by reservation id: zero change to a card when no plugin
contributes. Fetched once per view, fail-safe.

* docs(plugins): bring the permissions wikis current with this cycle

Plugin-Permissions.md was missing every permission added this cycle — add rows for
the read scopes (journal/atlas/vacay/daynotes/collections), the write scopes
(reservations/daynotes/packing, packing noting the #858 owner-scoping), jobs:run
and hook:table-contributor, and correct the events:subscribe row for the new
{ entity, entityId } hint. Add the jobs:run row to Plugin-Development.md too.

* fix(maps): stop quick one-finger pans zooming the map on mobile (#1440)

The global drag-drop-touch polyfill installs document-level touch listeners
on phones. On every single-finger touchend it records a timestamp, and if the
next touch starts within 500ms it synthesises a dblclick on the target, which
the map's default double-click-zoom turns into a zoom-in. Two quick one-finger
pans therefore zoomed instead of panning.

The polyfill only bridges HTML5 drag-and-drop to touch for planner reordering,
which is already disabled on mobile (#1432), so gate its import to viewports
>=1024px (the lg breakpoint useIsMobile uses). Removes the phantom-dblclick
source on phones while keeping touch DnD on large viewports; fixes both the
Leaflet and GL renderers.

* fix(feeds): emit TZID + VTIMEZONE so subscribed calendars respect time zones (#1453)

exportICS emitted timed DTSTART/DTEND as bare floating times (no Z, no TZID),
which iOS/Google Calendar render in the subscriber's local zone instead of the
zone TREK shows. Resolve an IANA zone per timed event — transport endpoints use
their stored timezone (departure drives DTSTART, arrival drives DTEND), while
assignments and hotel/restaurant reservations derive it from place coordinates
via tz-lookup — and attach TZID backed by a VTIMEZONE component. The all-trips
feed now carries deduped VTIMEZONE blocks so TZID references still resolve.

* feat(plugins): render tableContributor contributions in the places + day views

Extends the tableContributor frontend to all three planner views: hoist the shared
PluginCardFooter into PluginContributions, wire the places sidebar (keyed by place
id, rendered as a sibling after each row so the drag/scroll row stays untouched)
and the day panel (keyed by day id, guarded for a null day). Strictly additive +
fail-safe like the reservations view — nothing renders when no plugin contributes.

* fix(vacay): source holiday subdivisions from ISO 3166-2 so all states show

The state/region picker for public-holiday calendars was built from the
union of each holiday's counties for the current year, so a subdivision
only appeared if some holiday that year was tagged with it. States with
no state-specific holiday (e.g. US-WA, and AR/FL/NV/WY in 2026) silently
vanished, blocking calendar creation (#1456).

Source the full, correctly-named subdivision list per country from
ISO 3166-2 instead, merged with any nager county code ISO lacks. Only
region-partitioned countries get a picker, so nationwide-only countries
keep allowing a country-level calendar. No server change needed —
selecting a state already yields federal holidays via applyHolidayCalendars.

* fix(costs): settlements honor custom per-member splits (#1458)

calculateSettlement read each member's custom split amount but its query
never selected budget_item_members.amount, so hasCustomSplit was always
false and every settlement fell back to the equal split. Select bm.amount
so custom amounts drive the balances.

Also blank the Overview 'Per Person' / 'Per Person·Day' columns and CSV
for custom-split items, where a single averaged figure is meaningless.

* feat(plugins): read-convenience + todos + packing bags + tags + roster

A wave of small, high-value capabilities:
- weather:read (ctx.weather.get) — the host's cached forecast, tenant-free
- db:read:categories (ctx.categories.list) — the global place-category list
- db:read:tags / db:write:tags (ctx.tags) — the acting user's own tags, ownership
  re-checked before each write
- trips.members (ctx.trips.members) — the trip roster (id + display fields),
  membership-checked
- db:read:todos / db:write:todos (ctx.todos) — a trip's to-dos, gated by the app's
  packing_edit like the REST path, broadcasts todo:*
- packing bags on ctx.packing (listBags/createBag/updateBag/deleteBag/setBagMembers)
  under db:write:packing — no privacy, plain room broadcasts
perm labels (22 locales), both wikis, rpc-host gating + create-rpc-host wiring tests

* fix(dashboard): render next-trip boarding pass stats on Safari (#1459)

The boarding-pass bar carved its ticket-stub notches with a two-layer
radial-gradient mask composited via mask-composite: intersect (and legacy
-webkit-mask-composite: source-in). Safari mis-composites that multi-layer
path to fully transparent, hiding the entire stats bar while Chrome renders
it fine.

Split .hero-pass into an outer wrapper (left notch) and a .hero-pass-inner
glass panel (right notch), each carrying a single-layer mask so the
mask-composite path is never exercised. Renders identically across engines
and degrades safely where mask-image is unsupported.

* feat(plugins): write scopes for atlas, vacay, journal and collections

The write half of the user-scoped addon reads:
- db:write:atlas -> ctx.atlas.markCountry/unmarkCountry/markRegion/unmarkRegion +
  bucket-list create/delete. Every row is the acting user's own (visited_countries/
  visited_regions/bucket) — no trip scoping, no cross-tenant surface. Unblocks
  AirTrail-style two-way sync (#214)
- db:write:vacay -> ctx.vacay.toggleEntry/toggleCompanyHoliday. The plan is
  resolved HOST-SIDE from the acting user's active plan — a plugin can never name
  another plan, and toggleEntry only toggles the acting user's own PTO day
- db:write:journal -> ctx.journal.createEntry/updateEntry/deleteEntry, self-gated
  by journeyService.canEdit (owner/contributor) against the acting user
- db:write:collections -> ctx.collections.create/update/savePlace/copyToTrip/
  deletePlace, schema-validated; the service's per-collection role checks
  (assertAccess 404 / assertCanEdit 403) map onto RESOURCE_FORBIDDEN
All addon-gated, userless contexts refused, audited. Perm labels (22 locales),
both wikis, gating + wiring tests.

* fix(admin): name the Costs add-on consistently in the catalog

The budget add-on catalog entry still resolved to 'Budget' while the
feature is labeled 'Costs' everywhere else (trip tab, navbar). Align
admin.addons.catalog.budget.name with each locale's trip.tabs.budget
label. Closes #1464

* feat(plugins): file attach, collab content and gated member-add

- db:write:files -> ctx.files.create/createLink/update/softDelete under the app's
  separate file_upload/file_edit/file_delete rights. Content arrives as bounded
  base64 (10MB decoded cap, well under the app's 50MB), the extension is validated
  against the central blocklist BEFORE anything touches disk, and link targets
  must live on the same trip (findForeignLinkTarget). Broadcasts file:*
- db:write:collab -> ctx.collab.createNote/createPoll/votePoll/createMessage
  under collab_edit + the Collab addon, emitting the same collab:* events as the
  app; service-reported errors surface as BAD_PARAMS
- db:write:members -> ctx.trips.addMember. Adding a member GRANTS TRIP ACCESS, so
  it is deliberately its own permission behind the app's member_manage right
  (default: trip owner only) and never bundled with a lower-risk write; the acting
  user is recorded as the inviter, target must exist, owner/duplicate adds no-op
Perm labels (22 locales), both wikis, gating + wiring tests.

* fix(maps): honor check-in/out times for hotel bookend legs (#1465)

The day route drew the accommodation as the day's start/end whenever the
edge stop was a place, ignoring the morningIsSleptHere/eveningIsOvernight
provenance already computed by getDayBookendHotels. On a check-in day an
airport placed before check-in got a spurious hotel -> airport leg, and on
a check-out day a later "home" stop still got a home -> hotel return leg.

Add time-aware shouldDrawMorningLeg/shouldDrawEveningLeg helpers: the
morning leg is the home-base default on a check-in day but is dropped when
the first place is timed before check-in; the evening return leg is off on
a check-out day unless the last place is timed at/before check-out. Wire
them into the map polyline, the sidebar hotel connectors, and the Google
Maps export so all three stay consistent.

* feat(plugins): host-mediated notifications and LLM access

Two host-owned integration primitives — the plugin supplies intent, the host
owns the sensitive part:

- notify:send -> ctx.notify.send({title, body, link?, scope, targetId}). Delegates
  to notificationService.send with a new plugin_notification event (raw title/body
  carried as passthrough params), so recipient resolution, channel fan-out
  (bell inbox + email/ntfy/webhook) and per-user preferences all match core 1:1.
  Recipients are FORCED to the acting user (scope 'user', targetId === uid) or a
  trip they belong to (scope 'trip'); scope 'admin' refused; the in-app link must
  be a relative /path (open-redirect-safe). No arbitrary recipient, no impersonation.
  Users can mute plugin notifications like any other event.
- ai:invoke -> ctx.ai.complete(prompt) / ctx.ai.extract(text, jsonSchema). Runs the
  admin/user-configured provider via resolveLlmConfig + the existing extraction
  client under the acting user; the host holds the (encrypted) key, the plugin
  never sees it. Refused when no provider is configured; 20k-char caps. Output is
  DATA (complete -> {text}, extract -> {results}) and never auto-written, so
  prompt-injection can't reach a write without the plugin's own gated call.

plugin_notification wired through the shared NotificationEventKey + all 22 locales
(inbox passthrough + external channels). Perm labels (22 locales), both wikis,
gating + wiring tests.

* fix(budget): offer every Frankfurter-supported currency (#1470)

The cost currency picker was gated by a hardcoded 47-code list, so
currencies the app can actually convert (OMR, CRC, UGX, MKD, ALL, and
~115 more) couldn't be selected. Replace CURRENCIES/SYMBOLS with the full
set the Frankfurter v2 FX API supports (archived BGN/HRK dropped), unify
the dashboard offline fallback onto it, and teach currencyDecimals about
the newly reachable zero- and three-decimal currencies. A currenciesWith
helper keeps a previously saved (now-archived) selection selectable so it
isn't silently wiped.

* feat(plugins): tableContributor into the costs, packing and files views

Extends the shipped tableContributor hook to three more native views — no new
permission, no new attack surface: the same host-normalized, length-capped,
url-allowlisted (http/https/mailto), enum-bounded, fail-safe pipeline, just more
render sites.

- server: add costs/packing/files to the view-contributions whitelist
- client: widen the ViewName union + the api view type; render PluginCardFooter
  keyed by entityId in the budget category table (a colSpan footer row per item),
  the packing category group (footer after each item row, drag untouched) and the
  files list (footer after each row)

A currency plugin can now drop a converted-amount column onto a cost row, a
receipts plugin a 'view receipt' action onto a file, etc. Controller test asserts
the three new views are accepted; both wikis updated.

* fix(pdf): repeat day header on overflowing itinerary export pages (#1471)

* feat(plugins): map-marker provider hook — plugins can overlay trip-map markers

New declarative provider hook `mapMarkerProvider` (#587 "show bookings on map",
the single most-requested contribution class, with zero contribution point until
now):

- hook:map-marker-provider permission + MapMarkerProvider/MapMarkerContribution SDK
  types + HOOK_PERMISSION wiring
- GET /api/map-markers/:tripId (MapMarkersController) mirrors the view-contributions
  hardening: membership-gated, providers invoked host->plugin on a 5s timeout,
  fail-safe. Every field normalized server-side — coordinates range-checked
  (-90..90 / -180..180), strings String-coerced + length-capped, icon/tone enum-
  whitelisted, popup url http/https/mailto only (a javascript:/data: url would be
  click-XSS), marker count capped at 200 per plugin
- client: PluginMapMarkers layer renders the markers as plain Leaflet Marker+Popup
  inside the trip map; plugin JS NEVER runs on the map canvas, every value is
  host-vetted data. Threaded tripId through MapView; fail-safe fetch

Declarative-only by design, mirroring placeDetailProvider/tableContributor. Perm
label (22 locales), controller hardening test, both wikis.

* feat(plugins): show page plugins in the mobile bottom nav

Page plugins were reachable from the desktop nav pill (Navbar) but not the mobile
tab bar — you had to type /plugins/:id. BottomNav now reads page plugins from the
plugin store and appends them the same way global addons are, mirroring Navbar.
One-file client nav wiring; no new capability surface.

* feat(plugins): per-user plugin settings form + ctx.settings runtime read

Users can now enter their own per-plugin config (an API key, a preference) —
the prerequisite for almost every real integration, previously unreachable
(scope:'user' settings were only listed read-only in the admin panel).

- migration: plugin_user_config (plugin_id, user_id, config JSON) — each user's
  own values, separate from the admin-owned instance plugins.config
- PluginsService.getUserConfig / updateUserConfig / getUserConfigDecrypted +
  readUserSettingDecrypted: secrets encrypted at rest (apiKeyCrypto), masked to
  the client, an unchanged secret (the mask) keeps its stored ciphertext, and only
  DECLARED scope:'user' keys are ever stored
- GET/POST /api/plugin-settings/:id (PluginUserSettingsController) — its own
  user-gated path (not the admin surface, not the /:id/* proxy), JwtAuthGuard only,
  scoped to the acting user
- runtime: ctx.settings.get(key) -> the acting user's decrypted value (unconditional
  RPC, not sensitive cross-tenant; userless job/onLoad gets undefined)
- client: a Plugins tab in Settings host-renders each active plugin's scope:'user'
  fields as an editable form (secrets write-only), reusing the declarative field
  shape — no plugin markup executes

i18n (22 locales), wiki, rpc-host + service + masking/encryption tests.

* fix(journey): keep skeleton suggestions in sync with linked trip places (#1473)

Journey skeleton suggestions mirror a linked trip's day-assigned places, but
sync relied on scattered per-event hooks that several assignment mutation paths
never called: unassign, move and time-change fired nothing, no remove-on-unassign
capability existed, and every MCP assignment tool synced nothing. Skeletons drifted
from the trip.

Add an idempotent reconcileTripSkeletons(tripId) that re-mirrors the trip's
day-assigned places onto every linked journey (add missing skeletons, refresh
date/time/location on move, remove skeletons for unassigned places; filled entries
are detached + noted, never destroyed). Call it from every REST assignment handler
and MCP assignment tool, and fire onPlaceDeleted on single MCP delete_place for
parity. Extract a shared insertSkeletonEntry helper.

* fix(memories): drop hidden Immich assets so Live Photo motion parts don't show a broken thumbnail (#1474)

* fix(transit): anchor arrive-by search time to the destination timezone (#1479)

* feat(plugins): host-brokered OAuth client + trustworthy inbound webhooks

Two integration primitives where the host owns the sensitive part.

Trustworthy webhooks:
- auth:false routes now receive req.headers, but ONLY an explicit, credential-free
  allowlist (the common provider signature/event headers — stripe-signature,
  x-hub-signature-256, svix-*, x-gitlab-event, …). Cookie/Authorization/X-Socket-Id
  and every session/forwarded-auth header are stripped; authenticated routes get {}.
  A plugin can finally verify a provider signature without any way to leak a session.

Host-brokered outbound OAuth (oauth:client):
- the HOST runs the whole flow — authorize -> callback -> token exchange -> refresh —
  with PKCE + single-use, user-bound, TTL'd state, and HOLDS the tokens. The client
  secret + refresh token never leave the host; the plugin only triggers connect and
  reads a short-lived access token via ctx.oauth.getAccessToken() for the acting user.
- provider config (authorize/token url + scopes + client id/secret) is the plugin's
  admin-owned instance settings; endpoints must be https (SSRF backstop, private/local
  hosts refused). Tokens per-user + encrypted at rest (apiKeyCrypto).
- GET/POST /api/plugin-oauth/:id/{status,connect,callback,disconnect} — JwtAuth-gated,
  the callback always redirects to an in-app /settings path (never leaks an error).
- Settings -> Plugins gains a Connect/Disconnect control per configured plugin.

migration: plugin_oauth_tokens + plugin_oauth_state. Perm labels + form strings
(22 locales), both wikis, service (PKCE/state/exchange/refresh/encrypt) + controller
+ proxy header-allowlist + rpc-host gating + create-rpc-host wiring tests.

* fix(navbar): re-measure sliding tab pill after font load and resize (#1481)

The active tab pill was measured once in a layout effect keyed only on activeTab, so on a hard reload it captured the active (bold) label's width against fallback-font metrics and never re-ran when the web font swapped in, leaving the pill slightly offset.

Re-measure after document.fonts.ready resolves and on ResizeObserver changes (container + active button), with an idempotent state update to avoid redundant renders.

* fix(collections): keep the Add-place button reachable after the first save

On a wide/desktop layout the collection toolbar (which hosts the Add
button) was gated on !mapOverlay, so it unmounted as soon as the list
gained its first place with coordinates — leaving only an easy-to-miss
"+" in the map overlay. Keep the toolbar rendered whenever the user can
add a place, and drop the now-redundant map-overlay Add button so there
is a single, predictable Add affordance in every state.

Fixes #1485

* feat(plugins): days + accommodations reads/writes, endpoints on the reservation write path

Community feedback on the 3.2.1 plugin surface: a plugin could write days but
never list them (no way to learn day ids), day_accommodations had no surface at
all, and trips.getReservations was the one reservation read that dropped the
endpoints/day_positions hydration.

- trips.getDays / trips.getAccommodations under db:read:trips (tripRead gate),
  wired to the same dayService lists the REST GETs use
- trips.getReservations now returns the hydrated REST-parity list (endpoints,
  day_positions, joins, normalized accommodation_id) - strict superset
- new db:write:accommodations scope: ctx.accommodations create/update/delete
  gated by day_edit like the accommodations REST path, with the partner-hotel
  reservation + delete cascade and broadcasts intact
- reservation create/update pin the endpoints shape up front (BadParams instead
  of a mid-transaction NOT-NULL or a silently dropped row)
- perm label in all 22 locales, consent PERM_KEYS, wiki tables

* feat(plugins): day-detail widget slot in the day panel

Widgets can now mount inside the trip planner's day panel
(capabilities.widget.slot: 'day-detail'), scoped to the open day via a dayId in
trek:context - the same pattern as the place-detail slot. Covers the requested
per-day plugin content (logistics, outfit planning, live flight status) without
a new plugin type. Day-detail widgets stay off the dashboard, the consent panel
labels the slot in all 22 locales.

* feat(plugins): let the frame CSP serve a plugin's own static assets

The sandboxed frame runs at an opaque origin, so script-src 'self' never
matched and a plugin's own <script src>/<link> files were blocked - authors had
to inline entire React builds into index.html. Add a scheme-less host-source
pinned to the plugin's own /plugin-frame/<id>/ path (charset-checked Host +
plugin id so a stray token can't widen the policy; malformed Host falls back to
inline-only). Multi-file client builds now load as-is; remote hosts stay
blocked, so script URLs remain useless as an egress channel.

* fix(plugin-sdk): catch the package up to the server capability surface

The npm SDK's validator still knew only the 3.2.1 permission set, so
'trek-plugin-sdk validate' (and pack/publish, which run it) hard-rejected any
manifest using the newer scopes - db:write:reservations, notify:send,
hook:map-marker-provider and 25 more. Sync KNOWN_PERMISSIONS with the server
envelope (48 entries), mirror the full PluginContext (reservations,
accommodations, notify/ai/oauth/settings, packing writes + bags, file writes,
collab, tags/todos/daynotes/collections/atlas/vacay/journal, weather,
categories), type the tableContributor/mapMarkerProvider hooks + the
entity/entityId event hint, accept the day-detail widget slot, and extend
createMockHost so plugin unit tests can exercise all of it.

* feat(plugins): grant-scoped entity snapshots on core events

An events:subscribe handler so far learned only WHICH entity changed - useful
for cache busting, useless for reacting to content, and the userless handler
can't refetch. Now the broadcast tap derives a whitelisted field snapshot of
the changed entity and the supervisor attaches it per plugin, only where the
granted set holds the family's matching db:read:* permission (trips family ->
db:read:trips, budget -> db:read:costs, packing -> db:read:packing, dayNote ->
db:read:daynotes, file -> db:read:files). No acting user is ever synthesized.

The whitelists are explicit per family, so user ids (owner/paid_by/uploaded_by/
participants/members), trips.feed_token and future migration columns never
travel; a private packing item (#858) yields no snapshot at all because its
core broadcast is owner-scoped; deletes/reorders/bulk ops carry none.

* feat(plugins): pdf-section, atlas-layer and journal-entry provider hooks

Three more declarative provider surfaces in the map-marker mould - plugins
return data specs, the host normalizes, caps and renders; a slow or failing
provider contributes nothing:

- hook:pdf-section-provider: sections (title + paragraphs + a simple table)
  appended to the trip PDF export, escaped into the same HTML/print pipeline
  as the core content
- hook:atlas-layer-provider: per-user country tint layers on the Atlas map
  (ISO 3166-1 alpha-2 codes only, tone-whitelisted, non-interactive pane so
  mark/unmark clicks keep working)
- hook:journal-entry-provider: extra rows on a journal entry card, gated by
  the same journey access check as the journal routes + the Journey addon

Permission labels in all 22 locales, consent PERM_KEYS, SDK types + manifest
validator in both SDK copies, wiki tables, per-controller hardening tests.

* feat(plugins): trip-page plugins can replace core planner tabs and pick their spot

A trip-page plugin that takes over a core surface (a transit planner
superseding Transports, a costs plugin superseding the budget tab) had to sit
awkwardly next to the tab it replaces. capabilities.tripPage now names the
core tabs to hide while the plugin is active - whitelisted (transports,
buchungen, listen, finanzplan, dateien, collab), 'plan' deliberately not
replaceable, and the tabs return the moment the plugin is deactivated - plus
an optional 0-based position for the plugin's own tab. The feed re-validates
the values out of the DB blob so a hand-edited row can't hide anything else,
the admin list chips a replacing plugin (all 22 locales), and a saved session
tab that got replaced falls back to the plan view.

Also fixes the plugins feed dropping the day-detail widget slot to 'sidebar',
which would have mounted a day-panel widget on the dashboard.

* fix(plugins): audit follow-ups — normalization, secret cleanup, cron leak, slot filter

Adversarial audit of the whole plugin PR surfaced 12 confirmed issues; this
addresses them:

- place-details provider was the ONE hook controller with no normalization: a
  plugin's href/label/value went to the client raw and unbounded. Now normalized
  like journal-entry-rows (safeUrl http/https/mailto, length + count caps).
- trip-warnings capped message length + per-provider count (was unbounded).
- uninstall(deleteData) now also purges plugin_user_config, plugin_oauth_tokens,
  plugin_oauth_state, plugin_meta_migrations and the capability audit — encrypted
  per-user API keys + OAuth refresh tokens no longer survive a 'delete all data'
  and get silently re-adopted on a same-id reinstall.
- supervisor: a crash-restart cycle leaked the dead child's node-cron tasks and
  re-scheduled fresh ones, so a job fired N+1 times per tick after N crashes.
  onExit now stops them, mirroring kill().
- dashboard sidebar no longer mounts place-detail/day-detail widgets (they belong
  in the planner panels).
- reservation endpoint validation relaxed to match the 3.2.1 service: a coord-less
  endpoint is accepted and dropped downstream instead of BadParams (no breaking
  change), while a bad role/non-string still rejects up front.
- a replaced core tab reached by programmatic nav now falls back to the plan view.
- trips.update caps title/description like the places path; plugin-db guard bans
  load_extension as defense-in-depth.
- wiki: event snapshots, string-typed context ids, dayId in the payload, the live
  provider hooks and the costs update/delete grant are now documented correctly.

* feat(plugins): phase-0 lifecycle hardening + per-plugin RPC rate limit

Operational-readiness fixes from the completeness audit:

- Re-activation after a failure worked again: a plugin left in 'error' state by
  a load-failure or crash-auto-disable stayed in the running map, so the admin's
  'enable' button was a silent no-op. activate() now replaces a dead entry.
- Per-plugin RPC rate limit at the dispatch boundary: every ctx.* call runs
  synchronously on the host thread, so a plugin in a tight loop could freeze the
  whole instance (and the reap sweep). A token bucket (generous burst) + an
  in-flight cap now throttle a runaway plugin with a retryable HOST_ERROR; a
  legitimate plugin never notices.
- plugin_error_log retention (500 rows/plugin) so a crash-looper can't grow
  trek.db without bound; the crash-timestamp array is trimmed to its window too.
- TREK_PLUGIN_PERMISSIONS=off now logs a loud one-time warning that the OS
  permission jail is disabled.

* feat(plugins): read symmetry + broker — collab/journal/atlas reads, file content, trip create, rates

The plugin API leaned write-heavy: collab and journal could be written but not
read, files listed but not read, and there was no way to create a trip or see
exchange rates. This closes those gaps in the established RPC+gate pattern (zero
architecture risk), and it's what unlocks the importer + finance plugin classes:

- collab reads: ctx.collab.listNotes/listPolls/listMessages under a new
  db:read:collab (membership + Collab addon, like the REST GETs)
- ctx.journal.getEntries(journeyId): a journey's entries, journey-access-checked,
  under the existing db:read:journal
- ctx.atlas.bucketList(): the acting user's bucket list, under db:read:atlas
- ctx.files.getContent(tripId, fileId): a file's bytes as base64 under a NEW
  db:read:files:content grant (reading a passport scan is more sensitive than its
  filename), size-capped at 10MB before it crosses the IPC pipe, trashed files
  refused
- ctx.trips.create(input): a new trip owned by the acting user, gated by the app's
  trip_create right + a bound user — the capability importers need
- ctx.rates.get(base): cached currency exchange rates, tenant-free like weather

Also caps trips.update title/description like the places path, and the plugin-db
guard now bans load_extension (defense-in-depth). SDK, mock-host, i18n (22
locales), consent labels and the wikis are all in lockstep.

* feat(plugins): deeper integration + user-facing activity transparency

Wave 2 of the completeness work — richer extension points, deeper metadata, and
the transparency that makes the broad read grants accountable:

- db:meta now attaches to reservations + accommodations too (not just
  trip/place/day), gated by reservation_edit / day_edit respectively — the
  natural home for an external-id mapping (AirTrail/calendar/booking-import sync)
  without forking the core schema.
- reservation-detail widget slot: a widget can mount on a booking card, scoped to
  the open reservation via reservationId in trek:context (the place-detail /
  day-detail pattern, third instance).
- tableContributor gains the transports + todos views, so a plugin can add
  host-rendered columns/actions there too.
- User activity log: GET /api/plugin-activity + a Settings → Plugins panel showing
  every host-mediated action a plugin took bound to the signed-in user, across all
  plugins, newest first — the user-facing half of the hash-chained audit. This is
  what legitimizes the deliberately broad read grants: not just the admin, the
  person whose data is read can see what was done in their name.
- DX: the local dev server now binds a default acting user, so the canonical
  ctx.trips.getPlaces(tripId) call works locally instead of failing RESOURCE_
  FORBIDDEN; the create scaffold drops the dead manifest routes[] / capabilities.nav
  fields the host ignores.

SDK, i18n (22 locales), consent labels and the wikis are all in lockstep.

* fix(memories): load Immich album photos on Immich v3

Immich v3 removed the `assets` property from AlbumResponseDto, so
`GET /api/albums/:id` no longer carries album contents. TREK read album
photos from that property, which now parses as undefined and degrades to
an empty array — hence "No photos yet" in the Journey gallery picker even
though the album header shows the right count (that count comes from
`GET /api/albums` -> assetCount, which v3 still returns).

Two call sites read the removed property. Besides getAlbumPhotos (the
reported bug), syncAlbumAssets failed silently on v3: it reported
`success: true, added: 0` while syncing nothing.

Fetch album contents via an `albumIds`-filtered `POST /api/search/metadata`
when `assets` is absent, and feature-detect rather than probe a version.
The two paths are not interchangeable: on v2, searchMetadata
unconditionally scopes results to `[self, ...partners]`
(`asset.ownerId = ANY(userIds)`), so an albumIds search against an album
shared by a non-partner returns nothing. v3 added an albumIds branch that
checks AlbumRead and skips that owner filter. v2 also hard-defaults
`visibility` to `timeline`, dropping archived assets. So v2 must keep
reading the album detail body, which this preserves exactly.

`withExif: true` is required on the search path: it has no default and
gates an inner join, so without it Immich omits `exifInfo` entirely and
every photo's city/country goes null.

The existing test mock returned an album detail body *with* `assets` — it
encoded the v2 assumption, which is why this shipped green. It now models
v3 by default, with explicit v2 coverage asserting no search call is made.

Fixes #1492

* feat(plugins): daily AI/notify budgets, runtime scheduler & reliable event redelivery

Per-plugin daily caps on ai.complete/ai.extract and notify.send (defaults
200 / 100, overridable via TREK_PLUGIN_AI_PER_DAY / TREK_PLUGIN_NOTIFY_PER_DAY),
seeded from the capability audit so a mid-day restart resumes the count instead
of resetting it. Surfaced at GET /plugins/:id/budget.

ctx.scheduler (at / in / every / cancel): persistent, userless timers that
survive restarts and fire a scheduled() handler, riding the existing jobs:run
grant so no new consent or admin setup is needed. Backed by
plugin_scheduled_tasks, swept every 30s, capped at 100 tasks/plugin with an 8 KB
payload and a 60s recurring floor; rows are removed on uninstall.

Core events that fire while a subscriber is mid-restart are now held in a
bounded in-memory buffer (200/plugin, 15 min TTL) and replayed once it goes
active again, with the events:subscribe grant and snapshot gating re-evaluated
at replay time so nothing leaks if a grant was revoked while the plugin was down.

* feat(plugins): GDPR data-subject rights — durable per-plugin erasure + export

New hook:user-data grant with two userless lifecycle handlers a plugin can put
on its definition: deleteUserData and exportUserData. Neither carries an acting
user — the plugin only learns the userId and touches its own db — so the grant
reads nothing from core data; it exists purely so a plugin can honour a GDPR
erasure or data-access request.

When a TREK account is deleted (admin or self-service), every installed plugin
holding the grant gets a row in a new durable erasure queue and its
deleteUserData runs on the next sweep, retried until it ACKs — so erasure
survives the plugin being offline or the server restarting. The core deletion
path notifies the runtime through a dependency-free relay (like the event sink),
keeping the auth/admin services decoupled from the plugins layer, and a plugin
bookkeeping error can never fail the account deletion.

Portability is served by GET /api/admin/plugins/user-data/:userId/export, which
fans exportUserData out to the active granted plugins and aggregates what each
holds about the user. Queue rows are purged on uninstall; the grant is labelled
in all 22 locales.

* feat(plugins): atomic ctx.db.tx for consistent multi-write on a plugin's own db

Plugins could already query/exec/migrate their own SQLite file, but a multi-step
write (move an item between tables, decrement one row and increment another) had
no way to be atomic. db.tx([{sql, args?}, …]) runs up to 100 statements in a
single transaction — all commit or all roll back — and reads within the batch see
its own earlier writes, so read-modify-write is safe. Each op is one statement:
a read returns { rows }, a write { changes }. The same guard (no ATTACH/PRAGMA/
RECURSIVE, size + row caps) applies to every statement in the batch.

* fix(memories): filter hidden Immich assets at the source, not just the picker

#1474 has the same root cause as #1492: the Immich v3 migration. On v2,
searchAssetBuilder hard-defaulted metadata search to `timeline` visibility
(`visibility = options.visibility ?? Timeline`), so hidden Live Photo
motion parts could never come back from a search. v3 defaults to any
visibility except `locked`, so they do — which is why the reporter is on
Immich 3.0.1 and why the bug never appeared before.

Ask for `visibility: 'timeline'` explicitly on the search path. That
restores v2 semantics on both versions and stops hidden assets crossing
the wire, which also fixes a pagination wart: a full page half-made of
motion parts previously rendered as a half-empty page, because hasMore
counts the raw page length while the filter shrinks the rendered set.

The client-side filter was display-only, applied in searchPhotos and
getAlbumPhotos — both picker-listing paths. Nothing guarded persistence
or rendering: getOrCreateTrekPhoto stores any id it is handed, pipeAsset
forwards Immich's 400/404 verbatim, and the photo grid is a plain <img>
with no onError. So syncAlbumAssets, which filtered `type === 'IMAGE'`
only, could persist a hidden IMAGE as a permanently broken tile. It now
applies the same guard, extracted as isVisibleAsset().

Albums keep their filter rather than requesting `timeline` visibility:
albums legitimately contain archived assets, and both the v2 album body
and the v3 album search return them.

Does not address tiles already persisted before this — those still render
broken and need a separate fix.

Refs #1474

* docs(memories): correct Immich version boundaries in the hidden-asset comments

Verified against the v1.120.0 → v3.0.0 OpenAPI specs and server source. The
previous comments said "Immich v2 hard-defaulted metadata search to timeline
visibility". That is true only for 1.133–1.144.

- `visibility` was added in 1.133.0. Before that, searchAssetBuilder applied
  `.$if(options.isVisible !== undefined, ...)` with no default, so pre-1.133
  servers returned hidden assets too. #1474 was therefore not purely a v3
  regression.
- Those servers strip the `visibility: 'timeline'` filter rather than
  rejecting it: Immich validates with `whitelist: true` and no
  `forbidNonWhitelisted`. So the request stays valid, the filter is a no-op,
  and isVisibleAsset() is the ONLY guard there. Say so, so it does not get
  removed later as redundant.
- `albumIds` only exists from 1.135.0. Because unknown properties are stripped,
  an albumIds search against an older server would silently drop the album
  filter and return the entire library as the album's contents. Feature
  detection on `assets` (present through 1.144.1, absent on v3) makes that
  unreachable; a version probe with a wrong boundary would not.

Also cite Immich's own enum, which documents AssetVisibility.Hidden as
"Video part of the LivePhotos and MotionPhotos".

Comments only — no behavior change.

* feat(plugins): dashboard trip-card badges + a mock-host driver for plugin tests

Two additions that round out the plugin platform's breadth and its authoring DX.

tripCardProvider hook (hook:trip-card-provider): a plugin returns small declarative
badges for the dashboard trip cards. The dashboard fetches all visible cards in one
call; the host access-checks every tripId for the acting user, bounds each field
(label/value length, enum tone, http/https/mailto-only url), caps the count and drops
any badge for a card that wasn't requested — plugin JS never runs on the dashboard.
Rendered as text chips under the card meta; labelled + gated in all 22 locales.

createMockHost now exposes run(def) — the other half of a plugin unit test. Where the
ctx recorders capture what a plugin read, run() fires its own entry points (route, job,
scheduled, event, plugin-event, deleteUserData, exportUserData, provider hooks) against
the same mock ctx, and host.scheduled surfaces the timers it armed. A handler the plugin
didn't declare throws a clear error instead of a silent no-op.

* feat(plugins): include plugin data + code in backups, applied on restart

A TREK backup archived travel.db + uploads + the encryption key, but each plugin's
own SQLite file — the ONLY copy of the user data it holds — and its installed code
lived in separate trees that were never captured, so a restore left the plugins rows
with no data or code behind them.

createBackup now adds plugins-data/ (each plugin's db + WAL sidecars, so SQLite
recovers a consistent snapshot) and plugins-code/ (skipping dev-links by realpath, so
an author's linked source is never bundled). Restore can't swap those live — the
runtime holds each plugin db open — so it STAGES the extracted trees beside the live
ones and the runtime swaps them in at the next boot, before it opens anything. Same
"applies on restart" model the bundled encryption key already uses: no plugin quiesce,
no swap under open handles, no new admin setup. Older archives without the trees restore
exactly as before.

* fix(plugins): audit — runtime robustness, security & data-lifecycle fixes

Fixes from an adversarial audit of the plugin system, host/runtime side:

Robustness:
- getPluginDataDb recreated a handle a terminal-failure dispose had closed but
  left cached, so a re-enabled plugin's db:own threw on every call — recreate
  when the cached handle is shut.
- ctx.ws.broadcast* now carry _inv, so the host can bind the acting user (the
  capability was silently refused, i.e. dead, without it).
- ctx.events.emit swallows a rejected emit instead of crashing the child into a
  terminal 'error'; an uncaught throw AFTER activation is treated as a crash
  (restart with backoff), not a load failure.
- A crash-respawned child gets the same activation deadline as a first activation
  and the buffered-event queue is cleared on the timeout path, so a hung onLoad
  after a crash can't peg a core and orphan events forever.
- Expired buffered events are pruned by the reaper, not only at flush; the
  scheduler + erasure sweeps scope their LIMIT window to ACTIVE plugins so a
  backlog for inactive plugins can't starve deliverable work.

Security / integrity:
- Unix-domain-socket / named-pipe connects are refused by default in the egress
  guard (a host-local pivot to docker.sock / DB sockets), under the same policy
  as private IPs.
- db.tx refuses transaction-control statements (a raw COMMIT would break its
  atomicity) and caps rows across the WHOLE batch, not per statement.
- plugin_capability_audit is retention-capped per plugin (chain-safe: retained
  rows stay self-verifying), so it can't grow unbounded in the shared db.
- A cap of 0 in TREK_PLUGIN_AI_PER_DAY / _NOTIFY_PER_DAY now disables the broker
  instead of falling back to the default.

GDPR data lifecycle:
- Account deletion now erases host-side per-user plugin tables (config, OAuth
  tokens/state) and enqueues the own-db erasure from the CORE path, so it works
  even when the runtime is disabled or pre-boot; guest deletion does the same.
- uninstall keeps a pending erasure when data is retained (deleteData=false);
  erasure delivery is no longer grant-re-checked (a queued erasure is a duty);
  export flags installed-but-inactive plugins as pending instead of omitting them.

Backup/restore:
- Plugin DBs are WAL-checkpointed before archiving (no torn/stale snapshots).
- Restore applies the staged trees immediately by quiescing the plugins (no
  unbounded gap where a later unrelated restart would revert diverged data);
  the swap is content-level (safe on a volume-mounted root) and preserves
  dev-links; the decompressed-size cap is operator-raisable.

* fix(plugins): audit — hook-output hardening, dashboard slot & mock-host parity

- Map-marker and atlas-layer tones were validated on String(tone) but emitted
  raw, so a non-string tone (an object with a matching toString) slipped through
  and crashed the client that renders it — check the raw value against the enum.
- View-contribution column/action caps are now PER ENTITY, not per view, so a
  plugin's columns no longer vanish from every table row past the first 20; the
  dashboard trip-card badge cap is per card (≥ one on every visible card).
- A reservation-detail widget no longer also renders as a context-free dashboard
  sidebar card (the inline filter was missing that slot).
- mock-host matches the real host: it ignores asUserId on trip reads (bind the
  acting user), throws on a wrong user-scope notify target instead of coercing,
  enforces the scheduler caps, and detects RETURNING as a read in db.tx — so a
  passing author test can't hide a production RESOURCE_FORBIDDEN.

* feat(plugins): full ctx parity in the dev server + fire jobs/events/hooks locally

The trek-plugin dev server injected only ~6 of the ~35 ctx areas, so any plugin
touching ctx.costs/packing/files/notify/ai/settings/scheduler/meta/oauth/db.tx/…
hit a TypeError in local dev while the same code passed mock-host tests and worked
installed. It also could only exercise routes.

Delegate every non-db-own capability to a grant-enforcing mock host (the same one
unit tests use) while keeping the real node:sqlite for db:own and dev-native ws
capture + logging — so the whole surface works in dev with the exact production
permission rules. dev-fixtures.json now takes the createMockHost options shape, so
you can seed the full surface. New GET /__dev/fire/<kind>[/<name>][/<fn>] fires a
job, scheduled timer, event subscription, GDPR handler or provider hook against the
dev ctx, closing the "can't test non-routes locally" gap.

* feat(plugins): wire the photoProvider + calendarSource hooks to real core consumers

Both hooks were declared, typed and documented but NO core code ever invoked them,
so an author could build, mock-test and install a photo or calendar plugin that
silently did nothing. Give each a real consumer that fans out to it, exactly like
the other eight provider hooks:

- GET /api/plugin-photos/search (+ /sources, /item) aggregates photoProvider results
  for the picker — {id, title?, thumbnailUrl, fullUrl, takenAt?}, thumbnail/full URLs
  http/https-only (they become <img src>), per-source count capped, failing source
  skipped.
- GET /api/plugin-calendar?start=&end= aggregates calendarSource events for the
  signed-in user — {id, title, start, end, allDay} ISO, count capped, failing source
  skipped, sensible default window.

Both run with the acting user bound. The SDK interfaces now pass ctx as the last arg
(so a source can reach ctx.settings/oauth/http for its backend), and the wiki marks
them live instead of "reserved — no core consumer".

* feat(plugins): close the create-heavy API asymmetries importers/sync hit

Core services implemented these but plugins had no path to them, so the flagship
importer/sync integrations hit real walls. Added, each reusing the EXISTING grant
(no new consent):

- ctx.trips.removeMember(tripId, userId) — reconcile DEPARTURES, not just additions
  (db:write:members + member_manage). Never removes the owner (that would orphan the
  trip); ownership transfer stays a separate deliberate action.
- ctx.journal.createJourney({title, subtitle?, trip_ids?}) / deleteJourney(journeyId)
  — an importer can now bootstrap the journal it fills with entries and clean it up
  (db:write:journal), instead of only appending to journals a human created first.

Wired end-to-end (envelope → rpc-host → create-rpc-host reusing tripService/
journeyService → both SDK copies → mock-host) and documented. (trips.delete needs its
own destructive permission + consent copy and collab edit/delete + collections.delete
remain — tracked as small follow-ups.)

* feat(plugins): strip emojis from plugin-rendered text so it matches TREK's lucide UI

Plugin authors (especially AI-generated ones) sprinkle emojis into the declarative
text TREK renders in its OWN chrome — hook contributions (badges, columns, warnings,
PDF sections, map-marker/atlas labels, journal rows, place details, trip-card badges,
calendar + photo titles) and notifications — which clashes with TREK's lucide-only icon
language.

A shared stripEmoji() removes emojis (incl. flag/ZWJ/variation-selector sequences) and
tidies the leftover whitespace, applied at the render boundary in every hook-contribution
normalizer and in notify.send — so no matter what a plugin returns, the text TREK draws
stays emoji-free. It does NOT touch a plugin's own sandboxed /ui frame (the author's to
design), and it leaves photo ids verbatim (they round-trip to getById). The validate CLI
warns when a manifest name/description contains emojis, nudging authors to the declarative
`icon` field (a lucide name) instead.

* fix(plugins): harden the restore-apply path — regressions from the backup/dev fix pass

A final audit of the fix pass caught three regressions clustered in the two newest
surfaces; the restore path could both crash the server and destroy data.

- CRITICAL: a restore quiesces plugins via supervisor.shutdownAll() AFTER closeDb(), but
  shutdownAll killed children without first marking them stopped, so each child 'exit'
  took the CRASH path and wrote crash-accounting rows into the now-closed core DB — the
  throw escaped an EventEmitter listener as an uncaughtException and killed the whole
  process mid-restore. shutdownAll now marks every entry stopped and drops it from
  `running` BEFORE the kills (so onExit early-returns), and the onStatus/onLog DB hooks
  are wrapped in try/catch (also covers the stderr→onLog path). This also stops a normal
  shutdown from logging phantom "crashed" rows.
- HIGH: swapContents cleared live entries then MOVED staged ones in, so a crash mid-move
  permanently deleted a plugin's only data copy (staging was already emptied, so a retry
  couldn't restore it). It now COPIES each staged entry over the live one and only deletes
  staging at the very end — `staged` stays the complete source of truth, making the whole
  operation crash-idempotent.
- HIGH: the dev server lost the actingUserId=1 default in the mock-host refactor, so a
  fresh scaffold refused every user-bound capability. Restored.

* fix(plugins): final-audit medium/low findings

- GDPR export flags an active plugin whose export errored/timed out as `pending`
  instead of silently omitting it (collectUserExport now returns a discriminated
  result), so a data-access export never reads complete while missing data.
- Account deletion also enqueues an erasure for plugins UNINSTALLED with retained
  data (an orphan data dir) — a same-id reinstall now honours the deletion instead
  of re-adopting the user's data forever.
- oauth.getToken returns null in a userless context (matching the SDK/mock contract)
  instead of throwing RESOURCE_FORBIDDEN a background caller can't handle.
- Crash-backoff restart is identity-guarded (+ the timer is tracked and cleared like
  the activation timer), so a disable + re-enable during the backoff window can no
  longer respawn a ghost child from the replaced entry.
- db.tx transaction-control guard strips leading comments first, so `/* */COMMIT`
  can't slip past the start-anchored check and break batch atomicity.
- createJournal inherits its cover only from a trip that was actually LINKED
  (access-checked), closing a cross-tenant cover-image read on plugin + REST paths.
- trip-warnings drops a null array element instead of losing ALL of that provider's
  warnings; plugin-activity floors a non-integer ?limit so it can't 500.
- The trek-plugin dev server binds loopback only and refuses cross-site requests to
  its side-effectful /__dev/fire endpoints (it serves real routes + no-auth dev
  actions).

* fix(plugins): clear no-misleading-character-class in the emoji stripper

The character class listed the ZWJ, variation selectors and combining keycap
marks as members, which eslint reads as an accidental combined grapheme and
rejected on CI. Pull the emoji glyphs out into Extended_Pictographic /
Regional_Indicator alternatives so only the joiner/selector code points stay in
the class, with a scoped disable where the rule still can't tell them apart.
While here, reset lastIndex before the /g regex is reused in hasEmoji() so a
second call can't resume mid-string and miss a leading emoji.

* fix(security): trip-scope note-file deletion and guard the LLM base URL

Two reported issues:

- deleteNoteFile only matched on the note id and file id, so a member of trip A
  could delete a file attached to a note in trip B by guessing its id. Thread the
  trip id through the service and controller and scope the delete to it, the way
  every other collab operation already does.

- The LLM extraction clients fetched the user-configured base URL directly, so a
  user could point it at the cloud-metadata endpoint (169.254.169.254) and read
  the echoed error body. Route both clients through a new safeFetchLlm() that
  blocks the link-local/metadata range while still allowing a local or LAN Ollama
  (loopback and private ranges stay reachable), pinned to the resolved IP so a
  hostname can't rebind to the metadata address after the check.

* fix(security): route every LLM client through the SSRF guard

The base-URL SSRF fix covered the openai-compatible and anthropic clients but
missed the native Ollama /api/chat client and the /api/tags + /api/pull model-
management calls, whic…

* fix(plugins): repair plain-HTTP egress and forward the private-egress opt-out

Two pre-existing bugs in the plugin egress guard, found by running a plugin
against a real service end to end.

1. Every plain-HTTP request a plugin made was refused, whatever host it had
   declared. Node pre-normalises `net.connect()` args into an [options, cb]
   array and passes THAT array as the single argument; undici's plain-HTTP
   connector takes this path, its TLS connector does not. classifyConnect read
   `host` off the array, got undefined, and fell back to 'localhost' — so a
   fetch to a declared, public host was rejected with the nonsense message
   "localhost is not in the plugin's declared hosts". It failed closed, so it
   was never a security hole, and it went unnoticed because the only shipped
   egress plugin uses HTTPS. unwrapConnectArgs() unwraps the normalised form
   before anything reads host/path.

2. TREK_PLUGIN_ALLOW_PRIVATE_EGRESS could never have any effect. The guard that
   reads it runs INSIDE the child, whose env is scrubbed to a four-entry
   whitelist that never included it — so a documented setting (wiki/
   Environment-Variables.md) was wired to nothing, and no plugin could reach a
   self-hoster's LAN service no matter what the operator set. Forwarded only
   when set, so the default stays the secure block-private policy.

Regression tests cover the normalised form in both directions: the real host is
now resolved, and an undeclared host, a private IP and a unix socket are all
still refused when passed that way.

* feat(notifications): let a plugin register a notification channel

TREK's four channels (in-app, email, webhook, ntfy) were a closed set:
notificationService.send() dispatched with four copy-pasted `if` blocks and no
provider abstraction, so a fifth channel meant editing eight files by hand. A
plugin could produce a notification via ctx.notify.send(), but never deliver
one.

A plugin now registers a channel with `hooks.notificationChannel` +
`hook:notification-channel` on a plain `type: 'integration'` — not a new manifest
type, so the TREK-Plugins registry schema and both its CI gates are untouched.

Core refactor
- New channel registry (services/notifications/): email/webhook/ntfy become
  ExternalChannel providers wrapping the EXISTING send functions — no delivery
  logic is rewritten, only relocated. In-app deliberately stays out: it writes
  typed rows with scope/target/callbacks, not a rendered title+body, the same
  line shared/ already draws with i18n/externalNotifications.
- The event text is now rendered once per recipient instead of once per channel.
- The channel set is open: NotifChannel becomes a string, the matrix is
  registry-derived, and the UI columns are server-driven. The DB column was
  already bare TEXT and the Zod contract already a string record — only the
  TypeScript and the two UIs were ever closed.

The hook runs USERLESS. Every other hook is user-initiated, so actingUserId falls
out of the request; a notification is host-initiated for an ARBITRARY recipient,
so ctx.settings.get() would return undefined. The host resolves the recipient's
decrypted scope:'user' settings itself and passes them as an argument. That is
what lets a channel plugin be handed someone's push token WITHOUT being handed
the right to read their trips as them.

Enabling the plugin is the opt-in: a plugin channel is not gated on the admin's
`notification_channels` list. A built-in always exists in code and needs an
explicit switch; a plugin channel only exists because an admin enabled that
plugin. (Nothing could write a `plugin:` id into that CSV anyway, and the admin
toggle rebuilt it from three booleans, silently dropping anything else — so
requiring a second opt-in meant the channel could never be turned on at all.)

Also fixed, found while building this:
- Plugin settings keys were unvalidated, so a field named `__proto__` or
  `constructor` resolved off Object.prototype: a REQUIRED field with such a name
  reported as configured for every user who had configured nothing — enough, for
  a channel, to be dispatched to everyone with no credentials. Keys are now
  constrained at install and the config blob is parsed null-prototype, so it is
  impossible even for an already-installed plugin.
- A `select` field's options were cast straight through, so the obvious
  `["1","5"]` form rendered every dropdown entry BLANK (the client reads
  value/label). Now coerced, and malformed options are rejected.

Also adds: operator-supplied egress hosts (a plugin talking to a self-hosted
service can't name the operator's host at publish time, so an admin adds it
post-install and the runtime re-spawns the child with the widened allow-list —
only for a plugin that DECLARED operatorEgress, and only an admin, never a user);
settings-page actions (a "Test connection" button, user-initiated so
ctx.settings.get() returns the clicking user's own value); and a Gotify-shaped
notification-channel template in the SDK.

Verified end to end against a real Gotify container, not just in tests.

* docs(wiki): document the plugin notification-channel surface

Covers the pieces added in the previous commits, in the pages a reader would
actually reach for:

- Plugins.md (the admin-facing page) had none of it: notification channels,
  settings actions, and a full "Allowed hosts" section — including what
  operator-supplied egress deliberately does NOT let anyone do.
- Plugin-Development.md: the notificationChannel hook (and why it is the one hook
  with no acting user), settings-page actions, operatorEgress, and the manifest
  reference rows.
- Plugin-Cookbook.md: a "become a notification channel" recipe and a
  "Test connection button" recipe.
- Plugin-Permissions.md: hook:notification-channel, operatorEgress under the
  outbound section, and settings actions under "not a permission".
- Notifications.md: plugin channels alongside the four built-ins.

* fix(sdk): allow empty egress if and only if operatorEgress is true

* ci: don't run repo-specific workflows on forks

Guard release, publish, wiki-deploy and issue/PR-triage workflows with a
`github.repository` check so they no-op in forks instead of failing or
acting on the fork's own issues, PRs, tags and registries.

Also skip the Docker Scout scan for pull requests from forks: Docker Hub
secrets are never exposed there, so the login step could not succeed.

Tests and lint stay ungated — they need no secrets and are the gate for
incoming fork PRs.

* feat(sdk): add missing methods in mock-host

* fix(airports): rebuild the json file

* fix(airports.json): add small airports too

* fix(public transit): only show public transit option when a trip has actual dates

* fix(plugins): reap a queued erasure only once the plugin's data is gone

The orphan reap deleted every queue row whose plugin had left the registry, but
uninstall(deleteData=false) removes the plugins row while deliberately keeping the
data dir AND the queued erasure so a same-id reinstall can still honour it. The reap
now deletes a row only when the plugin's data dir is actually gone; a deleteData=true
uninstall already clears the rows itself.

* fix(backup): snapshot the core DB and swap restores atomically

createBackup archived travel.db via the archiver's lazy live-file read, so a WAL
auto-checkpoint firing mid-stream could write a torn database into the zip. It now
VACUUM INTOs a point-in-time snapshot and archives that, the same guarantee plugin
DBs already get. restoreFromZip swapped the DB by unlink-then-copy, which on an
interrupted restore could leave no valid travel.db; it now copies to a temp file and
renames it into place (atomic), dropping the stale -wal/-shm sidecars first.

* fix(deploy): Recreate strategy for the SQLite volume, pin the root compose image

The Helm Deployment had no strategy, so the default RollingUpdate would start a second
pod holding the same ReadWriteOnce PVC before the old one exits — a Multi-Attach
deadlock or two writers on one SQLite file. Default to Recreate (overridable for
ReadWriteMany). The root docker-compose.yml pinned trek:dev, a tag no workflow builds,
so a clone-and-up at the release tag ran a stale image; pin it to :latest like the README.

* fix(security): re-validate LLM endpoint fetch redirects per hop (GHSA-fmq9)

safeFetchLlm left undici's default redirect:'follow', so a configured LLM
endpoint could 302 to http://169.254.169.254/ and reach cloud-metadata
credentials — the DNS pin does not cover an IP-literal redirect hop, since
net.connect skips the pinned lookup for a literal IP. Follow redirects
manually now, re-resolving/re-checking/re-pinning each hop (allowing LAN/
localhost as before). Also block the Alibaba metadata IPs directly.

* fix(plugins): throttle the plugin log channel to prevent host-thread DoS

The per-plugin RpcRateLimiter only guarded the ctx.* (req) channel; ctx.log.*,
stdout/stderr and unknown evt topics reached a synchronous INSERT+prune on the
host thread unthrottled, so a while(true) ctx.log.error(...) loop could freeze
the instance. Route every plugin-driven log path through a per-plugin log token
bucket; excess lines are dropped with a summary line on resume.

---------

Co-authored-by: jubnl <jgunther021@gmail.com>
Co-authored-by: Dieter Blomme <dieterblomme@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: sld272 <zjrdmczh@outlook.com>
Co-authored-by: Nguyen Trong Binh <nguytb15@VN1N07HO1CD1015.local>
2026-07-11 22:28:32 +02:00
jubnl 3db2495bcd fix(sdk): bump version 2026-07-06 02:26:41 +02:00
jubnl 43d30245a0 fix(sdk): add supported plugin type in preflight 2026-07-06 02:25:40 +02:00
jubnl f4d1c0baa4 chore(plugin-sdk): release v1.3.0 2026-07-06 00:34:32 +02:00
github-actions[bot] ada18dd70d chore: bump version to 3.2.1 [skip ci] 2026-07-05 22:28:08 +00:00
jubnl 91025683bb 3.2.1 (#1433)
* fix(plugins): prevent arbitrary api access

* fix(planner): disable drag & drop on mobile so the places list scrolls (#1432)

On touch devices the draggable rows hijack the scroll gesture, so dragging
to scroll started an HTML5 drag and popped up the file-import overlay instead
of scrolling. Gate the draggable rows and the sidebar file-drop handlers on
!isMobile across the places sidebar and the day plan (places, transports,
notes), and hide the grip handle — the arrow reorder buttons take over there.

* fix(inspector): make the remove-from-day button icon-only on mobile

* fix(collections): show the save picker above the mobile place detail

* fix(plugins): seal IPC parent/child for good

* test(inspector): match the icon-only remove-from-day button

* feat(sdk): switch plain ts for clack/prompts interactive session

* feat(plugins): force-refresh the registry from the rescan button

The registry is cached for 30 min server-side and GitHub serves it with a
5-min CDN cache, so a freshly published plugin could take up to ~35 min to
appear. The rescan/reload button now force-pulls the registry: it bypasses the
in-memory cache and appends a cache-buster + no-cache headers to beat the CDN,
and refreshes the browse grid immediately.

* feat(sdk): bump plugin version

* fix(stored settings): prevent local storage drop when update not successful

* feat(plugins): sideload plugins by uploading a .zip

Adds an admin "Upload plugin" button + drag-and-drop to the plugins panel for
installing a plugin archive directly — handy for testing a build before it goes
to the registry. It reuses the registry install pipeline (slip/bomb-safe
extract, strict manifest validation, native-binary scan) via a new
POST /admin/plugins/upload, and only skips the registry sha256/signature checks
that a sideload can't have.

Sideloaded plugins are flagged (source "local:upload", a "Sideloaded" badge, no
GitHub link, no auto-update) and always land INACTIVE — replacing a running or
active plugin stops it and clears the active flag first, so new code never runs
without a fresh activation + permission consent.

* fix(planner): keep the day-plan collapse state after fully closing the page

The expanded/collapsed days were stored in sessionStorage, which survives a
reload but is wiped when the tab or window is closed — so every fresh open
re-expanded all days, which is tedious to re-collapse on long (10+ day) trips.
Store it in localStorage instead so a collapsed layout sticks until it's
changed.

* fix(i18n): correct Vietnamese translation of 'Disabled' (#1438)

'Tàn tật' means physically handicapped/disabled-person, not the
off/disabled state of a toggle. Replace with 'Tắt' (off), matching
the existing 'admin.plugins.stateOff' translation.

Affects admin.notifications.none and admin.addons.disabled.

* add the code of conduct

* fix(plugins): let widgets follow the in-app dark-mode toggle

The plugin frame is sandboxed at an opaque origin (no parent DOM access) and we
only sent the context — including the theme — once, on trek:ready. So toggling
dark mode in TREK left already-mounted widgets on the old theme until a reload.
Watch the <html> `dark` class and re-post the context when it flips; plugins
already re-apply the theme on trek:context.

* fix(plugins): deliver widget context on load so the theme is right on first paint

* fix(plugins): give widget cards the native glassy look and auto-height

Widget plugins rendered in a plain card with a fixed 180px body, so they looked
foreign next to the glassy dashboard tools and taller widgets had their controls
clipped. Mirror the native `.tool` surface (glass background/border/blur, uppercase
title) and let the body grow to the height the widget reports over trek:resize.

* feat(plugins): add read/rwite costs

* feat(plugin): better readme/index.js

* feat(plugin): better readme/index.js

* feat(plugins): hand widgets TREK's theme tokens, formats and display identity

Extends trek:context with a non-secret `tokens` map (TREK's resolved CSS design
tokens for the current theme), `formats` (currency/date/units/timezone) and a
`user` display object (name/avatar/isAdmin — never the email, role only as a
boolean). Re-sent on every theme toggle. A widget can now apply the tokens and
match the host exactly, in both themes and under a custom appearance, instead of
hard-coding a palette that drifts — so plugins feel native, not bolted-on.

* feat(plugins): hand plugins the full palette and appearance state

The theme context only carried a ~19-token subset read off <html> and only
followed the dark-mode toggle. Widen it to the whole global (:root/.dark)
palette — surfaces, text, borders, the accent family, semantic + soft fills,
shadows, radii and fonts — so a plugin tracks the user's chosen accent scheme,
custom accent and high-contrast live, not just light/dark. Also send an
`appearance` block (scheme, density, reduced-motion, no-transparency) mirrored
from the attributes applyAppearance writes on <html>, and re-post the context
whenever any of those actually change (a small signature dedupes unrelated
mutations) so plugins restyle in step with the app.

* feat(plugins): ship a design kit so plugin UIs look native

A plugin's UI is a sandboxed, opaque-origin iframe that can't load TREK's
stylesheet — so authors had to re-derive the whole look by hand, and most
didn't. Ship it instead: a token-driven stylesheet (glass, hover, buttons,
inputs, chips, rows) that consumes the tokens the host already sends and swaps
light/dark, plus a small bootstrap that applies those tokens, mirrors the
appearance flags, auto-reports the frame height and exposes a `window.trek`
helper over the existing bridge. Both are plain strings meant to be inlined
(the CSP forbids external assets for an opaque frame); `injectTrekUi` expands a
`<!-- trek:ui -->` marker. No new capability — only a native look.

* feat(plugins): deliver the design kit — native scaffold + inline on dev/pack

A new page/widget scaffolds a native, glassy starter that talks over
window.trek. The source keeps a single `<!-- trek:ui -->` line; `dev` (when it
serves /ui) and `pack` (as the file enters the archive) expand it into the
inlined kit — so the file stays a one-line opt-in and a rebuild always ships
the current kit. Existing plugins opt in the same way, by dropping the marker.

* feat(plugins): faithful themed host preview in dev

`dev` served the plugin UI raw at /ui — top-level, with no host — so the theme,
context and bridge never fired and authors couldn't see the design kit render.
Add /preview: it embeds /ui in a sandboxed opaque-origin iframe (exactly TREK's
isolation) and plays the host — posts trek:context with a theme/accent/appearance
toggle, proxies trek:invoke to your /api routes as the dev user, and surfaces
resize/notify/navigate. /ui stays as the raw doc for debugging.

* docs(plugins): document the design kit, window.trek and the token contract

Rewrite the client section of the Plugin Development wiki kit-first: the
`<!-- trek:ui -->` marker, the component classes, the `window.trek` bridge, the
`/preview` host preview, the full `trek:context` payload (now the whole palette
plus an `appearance` block) and how to apply tokens by hand. Add a "Build a
native UI" section + the new exports to the SDK README.

* feat(budget): add 'Outstanding amount' card

* fix(translations): finish translating new keys

* feat(plugins): trip-page plugins — a plugin tab inside every trip

Adds a `trip-page` plugin type whose sandboxed iframe mounts as a tab in the
trip planner (Plan / Transports / … / <plugin>), scoped to the open trip, with
no dashboard nav entry. This is the most-asked planner-extension request from
discussion #1429 (a plugin that lives in the trip, e.g. SimMesg20's budget
planner). It reuses PluginFrame and the existing tab system — the frame already
receives the current tripId over trek:context — so there is no bridge or
security change: only the manifest type enum (server + SDK), the client feed
classification (pluginStore.tripPages), and one render branch in the planner.
The SDK scaffolds it with `create --type trip-page`.

* fix(apple wallet): support for .pkpasses

* feat(plugins): permission-gated write APIs for the planner (#1429)

Plugins can now WRITE core planner data, not just read it, through curated,
membership-checked methods — so downstream features can live in plugins instead
of long-lived core patches. Four new scopes: db:write:places (create/update/delete
places), db:write:days (days), db:write:itinerary (assign/unassign a place on a
day) and db:write:trips (update trip fields).

Each ctx method mirrors costs.create: it validates the input against the SAME
@trek/shared schema the web app uses, binds the acting user host-side (a job/onLoad
has none, so its writes are refused), checks trip access AND the app's edit
permission (place_edit / day_edit / trip_edit), delegates to the real services,
broadcasts the same events so open sessions update live, and records the write in
the tamper-evident capability audit. No new route, no sandbox or CSP change — the
isolation boundary is unchanged; a plugin can only change what its user could change
by hand. Consent UI + permission labels in all 22 locales, SDK types + mock host,
and the wikis are updated.

* docs(plugin): ensure wiki correctness

* feat(plugins): plugin metadata on core entities — db:meta (#1429)

Plugins can now attach their OWN namespaced key/value data to a trip, place or
day without forking the core schema (#1429, request 2). New `db:meta` scope +
`ctx.meta.get/set/list/delete`. Storage is one plugin_entity_metadata table
(migration 161) keyed (plugin_id, entity_type, entity_id, key) — a plugin only
ever sees its own rows. Every call is membership-checked: the entity must belong to
a trip the host-bound acting user can access. Quotas guard the shared volume (≤64KB
per value, ≤100 keys per entity); rows are purged on uninstall-with-delete-data and
recorded in the capability audit. SDK types + mock host, a consent chip + labels in
all 22 locales, and the wikis. No new route, no sandbox change.

* feat(plugins): place-detail plugin slot in the trip planner (#1429)

A widget plugin can declare `capabilities.widget.slot: 'place-detail'` to mount
its sandboxed frame inside the trip planner's place-detail panel, scoped to the
open place — the frame receives the `placeId` in trek:context alongside the tripId.
This is the UI half of the place-detail-providers ask (reviews/ratings/popular
times shown on a place). It reuses the existing widget mechanism: PluginFrame gains
an optional placeId, the feed/store learn the new slot, and PlaceInspector renders
the slot at the foot of its body in trip mode. Admin chip + label in all 22 locales,
wiki updated. No sandbox or permission change.

* fix(plugins): green the server tests + harden the new capability surface

The in-memory uninstall fixture was missing the new plugin_entity_metadata table,
so uninstall's DELETE threw "no such table" and failed the server test job. Add the
table to the fixture schema.

Self-review hardening of the write/metadata surface:
- trips.update now reproduces the web UI's per-field gate: is_archived needs
  trip_archive and cover_image needs trip_cover_upload, not just trip_edit — so a
  member who may only edit can't archive or re-cover a trip.
- Plugin metadata WRITES now also require the entity's edit permission
  (place_edit/day_edit/trip_edit), not just trip access, so a read-only member can't
  overwrite or delete metadata another user created. Reads stay access-gated.
- Cap the metadata key length (<=256 chars) alongside the value/count quotas — the
  key was attacker-controlled and uncapped, defeating the disk-DoS guard.

* test(plugins): cover the new write/metadata deps to hold the coverage gate

The new create-rpc-host write + metadata deps were untested, dropping the
src/nest branch coverage below the 80% gate. Add a seeded in-memory core db plus
mocked core services to exercise every dep end-to-end: places/days/itinerary
create/update/delete + not-found paths, trips.update with the archive/cover
per-field gates and the Validation/NotFound/unknown-error mapping, metadata CRUD
+ key/value/count caps + access checks, the costs deps, and users.getById
scoping. Plus rpc-host cases for meta writes on place/day and a no-acting-user
refusal. Tests only — no production code change.

* fix(costs): freeze FX on every cost + settlement write path (#1445)

Settled foreign-currency costs kept re-opening with a few-cent residual
when live rates drifted. The #1335 freeze only ran on the REST create/
update path, so two gaps remained:

- Foreign-currency items created via MCP create_budget_item or booking-
  import bypassed the freeze and stored exchange_rate = 1, so settlement
  re-converted them with live rates. Promote freezeForeignRate into the
  shared budgetService and call it from every write path.
- Settle-up transfers were stored currency-less and re-converted with
  live rates on each recompute. Add currency + exchange_rate to
  budget_settlements (migration), freeze the display-currency rate at
  settle time, and convert with it in calculateSettlement. Legacy rows
  (currency = NULL / rate = 1) keep live-rate behaviour until re-edited.

Also expose guarded cost update/delete to plugins: costs.update and
costs.delete under db:write:costs, gated exactly like costs.create
(addon + trip access + the acting user's budget_edit permission).
updateCost reuses BudgetService.update so a plugin write re-freezes the
FX rate too; both broadcast the same budget:updated / budget:deleted
events the REST controller emits. Wired through the host, the runtime
SDK context and the published trek-plugin-sdk (types + mock host).

* feat(plugins): provider hooks — placeDetailProvider, wired (#1429)

Turn "hooks" from a declared-but-dead surface into a real host→plugin capability.
Add an invoke.hook branch to the child + a supervisor hook registry
(providersOf) + PluginRuntimeService.invokeHook, reusing the existing invoke
transport and its timeout (a short 5s deadline so a slow provider can't delay a
response; a job/onLoad has no user, host-bound as ever). Also fixes a real bug: the
in-repo runtime SDK copy was missing the `hooks` field entirely and could not even
parse a plugin that declared one — synced it with the published SDK.

The first wired hook is placeDetailProvider: a plugin returns extra rows
({label,value?,url?}) for a place, and TREK renders them natively at the foot of the
place-detail panel. Consumer is a new, additive, fail-safe endpoint
GET /api/place-details/:placeId (membership-checked; any provider that errors or
times out is simply skipped — it never breaks the panel). New hook:place-detail-
provider scope + consent chip in all 22 locales. SDK types (both copies), a
controller test, and the wiki. photoProvider/calendarSource stay reserved but the
transport now exists for them. No sandbox or CSP change.

* fix(files): handle pkpass in booking uploads and files-tab open (#1447, #1448)

Both bugs were client-only; the server already allows .pkpass and serves it
as application/vnd.apple.pkpass.

#1448: the reservation/transport attachment inputs hard-coded an accept list
that omitted pkpass, so macOS grayed it out. Add .pkpass/.pkpasses (+ wallet
MIME types) to the accept attribute in both modals.

#1447: the files-tab open path routed every non-media/non-markdown file into
the in-app PDF preview object. Add isWalletPass() and route wallet passes
through the shared blob openFile helper (as bookings already do), which
downloads them so the OS hands them to Apple Wallet.

* feat(plugins): validation/warning contributions via warningProvider hook (#1429)

Second wired provider hook, reusing the invoke.hook infra from the last commit. A
plugin implements warningProvider.getWarnings(tripId, ctx) → {level, message,
dayId?, placeId?}[] to flag problems on a trip (overpacked day, place closed on its
planned date, missing booking, …). TREK surfaces them as a non-blocking overlay
banner at the top of the trip planner (the wrapper ignores pointer events so it
never covers the map/panels; only the pills are interactive).

Consumer is a new additive, fail-safe endpoint GET /api/trip-warnings/:tripId
(membership-checked; a provider that errors or times out contributes nothing and
never blocks the planner). New hook:trip-warning-provider scope + consent chip in
all 22 locales, SDK types (both copies), a controller test, and the wiki. This is
the validation half of the scheduling+validation block; feeding durations/travel
times back into core recalculation stays out (it would touch core planner
computation — deliberately deferred to keep the no-breaking-changes guarantee).

* fix(plugins): enforce the hook:* grant on provider dispatch (#1429 audit)

The adversarial audit of the #1429 additions found one real (medium) gap: the
hook:* permission was never enforced at runtime. providersOf() selected provider
plugins purely by the hooks their CODE declares (sup.hooks, reported by the child
as Object.keys(def.hooks)) and never intersected that with sup.granted — so a
plugin that merely implemented placeDetailProvider/warningProvider got wired in as
a provider even when the admin never consented to hook:place-detail-provider /
hook:trip-warning-provider. The downstream capability router still held (the hook's
ctx can only do what the plugin's OTHER grants allow), but a plugin could obtain an
auto-triggered, user-bound execution context on a passive UI browse without the
hook being consented — a consent-integrity gap that contradicts the documented
invariant.

Gate it host-side: a hookName→permission map, and providersOf now returns a plugin
only if it is active, implements the hook, AND holds the matching hook:* grant. An
unmapped hook resolves to nobody. invokeHook additionally re-checks membership in
providersOf (defense-in-depth against a direct caller). Unit test proves the
grant/implements/active intersection.

* docs(plugins): add the Plugin Cookbook + a trip-doctor example (#1429 eco)

Fosters plugin authoring by turning the new #1429 capabilities into copy-paste
recipes. New wiki page Plugin-Cookbook (read a trip, write to the itinerary, tag an
entity with metadata, contribute native place details, raise trip warnings,
broadcast, match the TREK look) linked in the sidebar, plus a complete runnable
example — trip-doctor — a hooks-only plugin that showcases warningProvider +
placeDetailProvider + ctx.meta with zero UI of its own. Manifest validates against
the SDK. Docs/example only; no product code.

* fix(collections): don't reset saved-place status to 'idea' on edit (#1437)

The update schema reused collectionStatusSchema, whose .default('idea')
survives .optional() — so a PATCH that omits status had 'idea' injected by
the validation pipe and written to the DB, clobbering 'want'/'visited'.
Strip the default on the update field with .removeDefault(), keeping the
.catch guard. Add a shared schema regression test and an e2e round-trip.

* docs(plugin): ensure plugin scopes are the same everywhere

* feat(plugins): read scopes for packing + files (#1429 eco)

Extend the read side of the capability model beyond trips/costs: db:read:packing
→ ctx.packing.list(tripId) and db:read:files → ctx.files.list(tripId). Both mirror
the existing trip reads exactly — the host membership-checks the trip against the
invocation's user (tripRead) before delegating to the same packingService/
fileService the REST paths use (so bags/assignees hydrate and trash is excluded),
and each is a separate scope (packing doesn't unlock files). ctx types in both SDK
copies + mock-host, consent labels + cap chips in all 22 locales, rpc-host +
create-rpc-host tests, and the wiki (perm table + cookbook recipe).

* feat(plugins): core event subscriptions (#1429 eco)

A plugin can react to core activity by declaring events: [{ on, handler }] + the
events:subscribe grant. websocket.broadcast announces every CORE trip event (name +
tripId ONLY, never the payload) through a tiny dependency-free relay
(plugin-event-sink); the runtime registers a sink in onModuleInit and the supervisor
fans each event out to subscribed, granted, active plugins via a fire-and-forget
invoke.event on a short timeout — so a slow subscriber can never block a core write.

Safety by construction: handlers run with NO user (like a job) so trip reads are
refused — they react to the fact, using the plugin's own ctx.db/ws/outbound; the
grant is enforced host-side (deliverEvent checks events:subscribe); plugin:* re-
broadcasts are never delivered back, so handlers can't loop; and only the event name
+ tripId cross the boundary. SDK types (both copies), consent label + cap chip in all
22 locales, supervisor gating + broadcast-tap tests, and the wiki + cookbook.

The relay lives in its own module (not websocket) so it doesn't drag `ws` into the
runtime and tests that mock ./websocket don't strip the sink.

* feat(plugin-sdk): typed ctx returns + native trek.ui DOM helpers (#1429 eco)

Two author-DX wins, SDK-only.

Typed reads/writes: ctx.trips.getById/getPlaces/getReservations, packing.list,
files.list, costs.*, places/days/itinerary writes and users.getById now return
proper entity types (Trip, Place, Day, Reservation, PackingItem, TripFile,
BudgetItem, Assignment, User) instead of unknown — real autocomplete for authors.
Only `id` is guaranteed and every shape keeps an index signature, so it mirrors the
raw DB row honestly (no column hidden, no false guarantees). mock-host matches.

Native UI helpers: window.trek now carries `trek.ui` — a tiny bundler-free DOM
builder (el/button/card/chip/input/mount) that emits the kit's trek-* classes, so a
widget builds themed UI with no CSS and no build step. Ships inlined via the same
<!-- trek:ui --> marker. Wiki updated.

* fix(plugins): scope packing.list to the acting user's #858 visibility (eco audit)

The final eco audit found one real (medium) gap: the db:read:packing delegate
called packingService.listItems(tripId) with NO userId, which takes the UNFILTERED
branch and returns every member's private (is_private=1) packing items — leaking
another member's personal/surprise-gift items to a plugin the normal UI/REST hides
them from. The handler had the host-bound acting user but dropped it when delegating.

Thread it through: tripRead now hands the membership-checked userId to the read
callback, packing.list forwards it to listPackingItems(tripId, userId), and the
service applies its three-tier #858 filter — a plugin now sees exactly what its user
sees. files.list is unaffected (no per-user file visibility). Tests assert the user
is passed. The other three audited surfaces (event subscriptions, trek.ui, and the
regression sweep of the capability boundary) were clean.

* security(plugins): prevent open redirect

* fix(plugins): resolve PR #1433 full-audit findings (code + tests)

The comprehensive PR audit confirmed 21 findings; this fixes the code/test ones I own:

- ctx.users.getById was DEAD: the runtime SDK omitted the _inv tag, so actingUser
  never bound and every call hit RESOURCE_FORBIDDEN. Add _inv (the test had codified
  the bug — corrected).
- Plugin place writes bypassed the REST STRING_LIMITS (a 100k-char name the web app
  rejects). Mirror the caps (name 200 / description 2000 / address 500 / notes 2000).
- packing.list / files.list were missing from the capability audit log while every
  other core read is audited — add them to isAuditable + auditResource.
- SDK lockstep: CalendarSource.getEvents drifted (published Date vs runtime string);
  the host->plugin boundary is JSON, so align both to string.
- Admin panel didn't know the new trip-page plugin type (unlocalised badge, missing
  filter) — add it to KNOWN_TYPES + the type filter + a 22-locale label.
- Tests for previously-uncovered paths: the child-side invoke.hook/invoke.event
  dispatch (real fork, hook + event + non-matching-subscription), and invokeHook's
  defense-in-depth grant re-check.

Julien's settlement-FX-refreeze finding is his budget code (flagged, not touched).

* docs(plugins): correct the wiki against the shipped capability surface (#1433 audit)

Fixes the 11 doc findings from the PR audit — every corrected claim was cross-checked
against the code:

- Plugin-Development: CSP connect-src is built from granted http:outbound:<host>, not
  egress[]; dropped the stale "costs.create is the first and only core mutation";
  documented costs.update/delete + ctx.packing/ctx.files; the manifest permission table
  gained the six missing scopes (db:write:places/days/itinerary/trips, db:meta,
  hook:trip-warning-provider); the widget slot table gained place-detail.
- Plugin-Cookbook: days.create no longer passes a title the schema drops;
  broadcastToUser uses the real (userId, event, data) signature; fixed the broken
  #the-trek-ui-design-kit anchor and noted window.trek.ui.
- Plugin-Permissions: added db:read:packing, db:read:files and events:subscribe;
  the provider hooks are implemented in `hooks: {...}` on the definition, not on ctx.

* fix(unsplash) allow api key usage

* fix(guests): scope guest display names per-trip, not globally (#1446)

A guest is a per-trip person, but their name lived in the globally UNIQUE
users.username, so uniqueGuestUsername() auto-renamed a second "Jake" (on any other
trip) to "Jake 2". Add a non-unique users.display_name: a guest now stores the human
name there and gets a uuid-based username that is never shown, and every member view
(members list, day-assignment participants, budget members/payers, packing
recipients/contributors/bags/assignees) COALESCEs display_name over username. Rename
updates display_name with no dedup. Real users are unchanged (display_name NULL →
COALESCE falls through to username). Migration adds the nullable column; existing
guests keep their current username via the COALESCE fallback.

This also unblocks ctx.users.getById (the audit's #4 fix), whose projection selects
display_name. Tests: two "Jake" guests on two trips both keep the name; the two
codified-the-old-behaviour guest tests corrected.

* fix(costs): don't re-freeze a settlement's FX rate on an unrelated edit (#1445)

The full audit found that updateSettlement called freezeForeignRate without the
"currency unchanged" guard the item path has, so any edit of a foreign-currency
settlement (e.g. correcting from/to) re-fetched the LIVE rate and overwrote the
frozen one — re-opening an already-balanced position with a small residual, the
exact drift #1445 was meant to prevent.

freezeForeignRate's unchanged-check was item-centric (it queried budget_items),
which a settlement (a different table) can't use. Give it an explicit
existingCurrency param; updateSettlement now reads the settlement's stored currency
and passes it, so an edit that doesn't change the currency keeps the frozen rate
(the service UPDATE already preserves exchange_rate when it's left unset). Tests
cover both: unchanged currency keeps the rate, a real currency change re-freezes.

* feat(plugins): add inter plugin dependency support and addon dependency support

* feat(plugins): add inter plugin dependency support and addon dependency support

* docs(plugins) inter dependencies

---------

Co-authored-by: Maurice <mauriceboe@icloud.com>
Co-authored-by: trongbinhnguyen <43725147+trongbinh15@users.noreply.github.com>
2026-07-06 00:27:42 +02:00
github-actions[bot] b26be30a25 chore: bump version to 3.2.0 [skip ci] 2026-07-04 23:17:14 +00:00
github-actions[bot] 7dc921dff3 chore: bump version to 3.1.5 [skip ci] 2026-07-04 23:13:44 +00:00
Maurice 7eabf6066f 3.2.0 (#1426)
* docs(wiki): document the snap Docker + no-new-privileges startup failure

* fix(setup): warn when ADMIN_EMAIL/ADMIN_PASSWORD are ignored, ship reset-admin

The first-run seeder only applies ADMIN_EMAIL/ADMIN_PASSWORD on an empty
database and then silently ignores them. People add the vars after the first
boot, or pull a fresh image without clearing ./data, restart, and cannot log
in with no hint why (#1339). The default is a generated password (not the
.env.example placeholder), printed once in the first-run box. Now: warn loudly
when the vars are set but a user already exists, and warn on a partial
(one-of-two) config instead of quietly falling back.

Also ship the reset-admin recovery script in the image -- it was never COPYed in
despite the wiki referencing it. node server/reset-admin.js resets/creates
admin@trek.local with a generated password (RESET_ADMIN_EMAIL/RESET_ADMIN_PASSWORD
overridable), picks a free username so it cannot trip UNIQUE(username), and sets
must_change_password.

* feat(extract): extract data using LLM

* fix(extract): auto-run the AI fallback when the addon is enabled

Booking import only fell back to the LLM when each user flipped an 'always retry with AI' toggle, so by default files kitinerary returned nothing for just failed. Run the fallback automatically whenever the AI Parsing addon is on (fallback-on-empty); drop the now-redundant per-user toggle and its setting.

* fix(extract): make AI imports reliable and fast on local models

client: the import call inherited the global 8s axios timeout and aborted long LLM extractions even though the server finished it; remove the timeout. server: raise the OpenAI-compatible LLM timeout 60s->180s (a cold Ollama model can take ~45s to first token). server: cap extracted text to 8000 chars before the LLM - multi-page T&C tails (30k+ chars) overflowed the context window, truncating the relevant head and making CPU inference crawl; booking details sit at the top.

* feat(extract): fill transport/booking fields, geocode endpoints, assign days

- rental car: request+map dropoffLocation, emit pickup->return from/to endpoints, set a location string (G1/G2/G3). - geocode endpoints (stations/stops/terminals/rental desks) on confirm via Nominatim; mapper now emits coordless named endpoints and confirm persists only the geocoded ones (G6). - assign every dated booking to the nearest trip day so it still shows when slightly out of range, and keep hotel accommodation from vanishing when a check date misses (G5/G10). - fix bus mislabelled as train + add bus_number metadata (G7/G8), flag malformed boats (G9), accept root start/end time for events (G11). - raise the local-LLM timeout to 300s for CPU-only Ollama.

* perf(extract): cap LLM input at 4000 chars for CPU-only speed

On a GPU-less host the model's prompt-eval time scales with input length and dominates total latency. Booking details sit at the top of a confirmation, so capping the extracted text at 4000 chars (was 8000) roughly halves extraction time (~50s warm for a capable local 7B model) with no loss of fields on real hotel/rental confirmations. Tunable if a long multi-segment itinerary needs more.

* feat(extract): capture seat, class, platform, price + event venue contact

Request and map root-level seat/class/platform and a total price/currency into reservation metadata (shown on the card; price reuses the existing label). Read both the root and reservationFor and tolerate common field-name aliases (priceAmount, priceCurrencyISO4217Code, fareClass, ...) since models name these inconsistently. Also capture event/attraction venue telephone + url onto the auto-created place, matching lodging/restaurant.

* feat(extract): create a linked cost from the booking price on import

When a confirmation carries a total price, record it as a real expense
linked to the reservation (in the matching Costs category) instead of
leaving the amount in metadata only. Gated on the Costs addon.

* fix(extract): refresh accommodations after a booking import

A freshly imported hotel links to an accommodation that lives outside the
trip store, so loadTrip alone left the reservation edit modal with blank
place/date fields. Reload the accommodations list once the import finishes.

* feat(extract): drive NuExtract with its native template

NuExtract isn't an instruct model — fed a plain chat prompt it just echoes the
schema back. Detect a NuExtract model by id and talk to it the way the model
cards document: the JSON template inlined in a single user message, no system
prompt, no json_schema, temperature 0. Its flat result is mapped back to the
same KiReservation shape the rest of the pipeline already uses, so nothing
downstream changes; every other model keeps the generic prompt.

Money is taken as a verbatim string and parsed locally (German "1.580,22 €"
otherwise comes back as 1.49772), a rental car's pickup/return ride the from/to
fields so a stray form label doesn't become the location, and a lodging with no
name falls back to its address instead of being dropped.

* fix(admin): tidy the AI parsing settings and recommend the 2B model

The provider picker is the shared CustomSelect now and the form is split into
clear sections rather than a flat stack of inputs. NuExtract 2.0 2B is the
recommended default — fastest on a CPU-only host and MIT licensed; the 4B
carries a non-commercial licence, so it's no longer flagged as recommended.

* feat(import): review each parsed booking before it's saved

Instead of writing parsed items straight to the trip, the import opens the
normal edit modal pre-filled for each one, so you can check and fix it before
saving — useful when a model guesses a wrong date or address. Hotels gained an
editable address field; on save an existing place is matched by name, otherwise
the reviewed address is geocoded and a new place is created.

* feat(extract): drive local parsing through a layered extraction router

The single-shot prompt was unreliable on multi-leg flights and longer
documents, and slow on a CPU host. For the local provider, run a small
router instead:

- deterministic vendor templates first, with no model call at all
- exactly one grammar-enforced call per document via Ollama's native
  `format` (flights as a flat array of legs, everything else as one flat
  reservation, the type picked from keywords or a union schema)
- booking-wide fields (booking reference, total price, the overnight
  arrival day) filled deterministically from the text afterwards, and
  dates coerced to ISO so a natural-language date can't slip through

Recommend qwen2.5 in the AI-parsing settings instead of NuExtract.

* feat(import): parse bookings in the background with a progress widget

Parsing a booking can take a while on a CPU host, so don't hold the
upload modal open for it. The async import endpoint returns a job id
right away; the parse runs server-side (one at a time per user) and
pushes progress over the user's WebSocket, and a small widget in the
bottom corner tracks it while the user keeps navigating and editing.
A finished job opens the per-item review from the widget.

* fix(import): create linked costs and accommodations from reviewed bookings

Reviewing an imported booking saves it through the normal reservation
form, which dropped the parsed price (so no linked cost was created) and
only created the accommodation when both nights matched a trip day.
Carry the parsed price into a linked cost on save, and create the
accommodation from whichever day the check-in/out dates resolve to.

* feat(extract): add Expedia and rental-broker booking templates

Pull the hotel/rental fields these vendors print in a stable text layout (name, address, stay/pickup dates, price, reference) deterministically, so the import stops depending on the local model for them. Handles German long/abbreviated months and English dates incl. 12-hour and comma forms.

* fix(extract): backfill booking code/total and harden the reference match

Apply the deterministic confirmation-code and total fill to vendor-template results too (not just model output), and require the captured reference to contain a digit so a bare 'Confirmation'/'Reference' label no longer grabs the next prose word.

* fix(import): keep the parse-progress widget across a reload

Persist the background-import tasks (id/trip/status only) and re-fetch each job's status on mount, so a parse still running when the page reloads keeps its widget instead of vanishing; expired jobs (404) are dropped and a restored 'done' task re-fetches its items.

* fix(reservations): skip un-geocoded endpoints instead of failing the save

reservation_endpoints.lat/lng are NOT NULL, so saving a reviewed transport whose pick-up/return couldn't be geocoded threw a 500 and lost the whole booking (dates, linked cost). Skip those rows; the dates still persist on reservation_time/reservation_end_time.

* fix(import): resolve an imported transport's day from its parsed dates

A reviewed transport (e.g. a rental car) arrived with only its parsed pick-up/return dates and no day_id, so the modal kept just the time and saved a bare "HH:MM" with no date. Resolve start/end day from the parsed dates (exact match, else nearest trip day) so the booking lands on the right days.

* fix(import): refresh costs after a booking review so imported expenses appear without a reload

Imported bookings auto-create their linked budget items server-side, but the saving client suppresses its own budget:created echo, so the Costs list stayed stale until a manual reload. Reload the budget items when the review session ends.

* refactor(extract): dedupe currency/day helpers, drop redundant casts, support JPY vouchers

Code-audit clean-ups: share one normCurrency between the router and the templates, lift the duplicated nearest-day resolver into formatters.resolveDayId, drop two needless as-unknown-as casts at the fillBookingWideFields call sites, restore routeExtraction's doc comment, and give the broker template readable names. Plus recognise ¥/JPY and fall back to a standalone symbol amount, so a Klook-style voucher whose price sits far from any label still yields a cost.

* feat(import): attach the parsed source document to each booking

Keep the uploaded files on the background task and hand them to the review flow, so each reviewed booking pre-fills its Files with the document it was parsed from (uploaded with the booking on save). The two modals also adopt the shared resolveDayId helper.

* fix(extract): disable model thinking for grammar-constrained extraction

Hybrid/reasoning models (Qwen3 and similar) default to emitting reasoning tokens, which collide with Ollama's format-grammar constraint — on CPU this produced null/unparseable output and blew the latency budget (qwen3:8b: null or 300s timeouts vs ~20s with thinking off). Send think:false on the /api/chat call; Ollama ignores it for non-thinking models (verified on qwen2.5:7b), so it's safe and unlocks the stronger Qwen3 family.

* feat(extract): recommend Qwen3-8B as the local extraction model

A/B against the prior default (qwen2.5:7b) on CPU showed Qwen3-8B is both faster and more accurate on tricky/multilingual booking docs (correct Airbnb year+price, correct DisneySea admission date), once thinking is disabled — which the router now does. Feature it as the recommended pull, keep qwen2.5:7b as the fallback.

* refactor(extract): drop vendor templates, let the model drive with deterministic backfill

Now that a capable instruct model (Qwen3-8B, thinking off) reads name/address/dates/legs reliably across formats, the per-vendor template short-circuit distorted more than it fixed: brittle on layout variations and overriding the better model output. Remove the template layer; the model extracts the structure and Schicht 2 backfills the confirmation/total and takes the currency from the document's own symbol (correcting model misreads like ¥→$). Per-type prompts now also ask for address and price/currency.

* fix(extract): require the hotel address and ask for the rental company

After dropping the vendor templates, the model skipped the (often unlabeled) Expedia-style hotel address — making address a required schema field forces it to emit the street-address line, restoring the booking's location/place. Also hint the rental company so a car booking gets a real title instead of the generic fallback.

* fix(import): refresh costs immediately after an imported booking is saved

The saving client gets no budget:created echo (X-Socket-Id) and the create response omits the linked budget item, so the booking's Costs section and the Costs tab stayed stale until a manual reload. Reload the budget items right after a create that carried a budget entry.

* perf(extract): cap single-booking text tighter; require rental company

A long single-booking PDF (e.g. an 11-page rental voucher) spent ~200s on CPU prompt-eval at the 16k cap, though its data sits in the first ~2k. Cap non-flight docs at 6k (flights keep 16k for all legs). Also make the rental operator a required field so the car gets a real title.

* fix(import): preview the parsed cost as linked in the review modal

During the per-item import review the booking isn't saved yet, so the Costs section showed an empty 'Create expense' even though a linked cost will be created on save. Show the parsed price (amount + category) as the pending linked expense so the user can verify it up front. Reuses existing i18n keys.

* fix(import): persist source files in IndexedDB so attach survives a reload

The source document was only kept in memory on the background task, so a page reload during the (now always-LLM ~25s) parse lost it and the booking saved without its file. Store the uploaded files in IndexedDB keyed by job id; the review loads them from there when the in-memory copy is gone, and a 1h TTL prunes abandoned imports.

* chore(extract): recommend only Qwen3-8B (drop Qwen2.5 from the curated list)

Qwen3-8B is the identified default; the prior Qwen2.5 entries are no longer needed in the pull list.

* feat(settings): let users set their own AI parsing model

Adds an "AI parsing" section under Settings -> Integrations where a user can choose the LLM provider, model, base URL, API key and multimodal option used for booking extraction. This per-user config applies when an admin has not configured an instance-wide model. Reuses the existing encrypted user settings: the API key is stored encrypted, never prefilled, and a blank field keeps the stored one. Adds settings.aiParsing.* across all 20 locales.

* fix(settings): show the Integrations tab when only AI parsing is enabled

hasIntegrations gated the tab on memories/mcp/airtrail only, so a user with just the llm_parsing addon enabled saw no Integrations tab and could not reach the AI parsing config. Include llmEnabled in the gate.

* feat(settings): use the shared custom dropdown for the AI parsing provider

Swap the native select for CustomSelect so the provider picker matches the rest of the app's styling (dark mode, portal dropdown).

* refactor(planner): move the import-review bridge effect into the page hook

TripPlannerPage held a useEffect (the background-import → review bridge), which trips the page-pattern check (pages must stay wiring containers). Move the effect and its store/IndexedDB wiring into useTripPlanner where the rest of the import-review state already lives.

* test(llm-parse): cover the extraction router, client factory and import jobs

The new LLM extraction router shipped with little branch coverage, dropping src/nest below the 80% gate. Add unit tests for routeExtraction (flights/single/union/error paths, deterministic booking-wide fill), the native Ollama format client, the provider factory, the local-router service path with its type-aware text cap, the flat->schema.org mapper's remaining reservation types, and the background import-jobs runner. Also remove the now-unused validate.ts (only its FlatLike type was still referenced; moved to flat-schemas).

* test(setup): stub websocket addListener/removeListener in the global mock

BackgroundTasksWidget (mounted globally in App) subscribes via addListener/removeListener from api/websocket, but the global test mock didn't export them, so every test that renders <App/> threw on mount. Add the two stubs. (Surfaced now that the page-pattern check passes and the client test step actually runs.)

* fix(i18n): add Swedish translations for the AI booking-import settings

The Swedish (sv) locale landed on dev (#1325) after this branch added the
AI-parsing settings/reservation keys to the other locales, so sv was missing
them — strict i18n key parity failed after rebasing onto dev. Adds the 3
reservations.import.* and 17 settings.aiParsing/aiAlwaysRetry keys in sv.

* fix(extract): don't let the day-clamp fallback break reservation resync (#1288)

This branch added a clamp-to-nearest-day fallback to resolveDayIdFromTime so an
imported booking whose exact date has no day row still lands on a day. After
rebasing onto dev, that collided with #1288's resyncReservationDays, which
relies on the original "null when no exact day" semantics to leave a booking
whose date now falls outside the range untouched — instead it snapped to an edge
day (TRIP-SVC-019 failed: expected day_id kept, got the clamped one).

Make clampToNearest an opt-in parameter (default true, preserving the import
behaviour for create/update) and have resyncReservationDays pass false, so
out-of-range bookings keep their day_id. Full server suite green (4082).

* Added focus to search places in placeFormModal

* fix(airtrail): import departure/arrival times for manually-entered flights (#1336)

The mapper read only `departureScheduled`/`arrivalScheduled`, but those columns
are optional in AirTrail and stay null for manually-entered flights — where
`departure`/`arrival` are the only times set. So the import dropped the departure
clock (date-only) and the whole arrival (no date, no time), exactly as reported.

AirTrail's own rule is "use departure if available, otherwise fall back to
departureScheduled". Mirror that: prefer the scheduled instant, fall back to the
primary departure/arrival, in mapFlightToReservation, normalizeFlight, and the
sync hash. Hashing the resolved instant means flights already imported without a
scheduled time re-sync once and pick up their clock automatically; flights that
do have scheduled times are unaffected (no spurious re-sync).

Tests: 3 new mapper cases (fallback mapping, picker preview, hash tracking);
two existing cases that asserted the scheduled-only behaviour updated to the
"neither time set" case. Full server suite green (4085).

* fix(pwa): stop unregistering the service worker on offline boot (#1346)

Opening the installed PWA offline showed Chrome's "no internet" page instead of
the cached app. On boot the axios response interceptor reacts to a failed
request with no response by probing /api/health; the probe collapsed "genuinely
offline" and "edge-proxy auth wall" into a single reachable=false, so the
interceptor unregistered the service worker and reloaded — straight into a dead
network. navigator.onLine is true on mobile while offline, so the existing guard
didn't help. This also defeated the offline data layer (withOfflineFallback,
authStore's offline branch), which runs later in the chain.

Fix: connectivity.probe() now returns a discriminated state
('online' | 'offline' | 'proxy-wall'). A fetch that throws, or navigator.onLine
false, is 'offline'; a cross-origin redirect (CF Access, via redirect:'manual'
→ opaqueredirect) or an HTML auth wall (Pangolin) is 'proxy-wall'. The
interceptor only tears down the SW on 'proxy-wall'; on plain offline it lets the
request reject so the cached shell + IndexedDB serve the app. CF Access /
Pangolin reauth still works — the proxy always presents a reachable redirect or
HTML wall, which the probe now detects positively.

Regression dates to v3.0.16 (#964), surfaced by the 3.1.0 rewrite.

Tests: 6 new connectivity cases (offline/online/proxy-wall discrimination);
client tsc clean, full client suite green (2850).

* fix(map): keep the mobile GPS button above the day-detail panel (#1348)

On mobile the location (GPS) FAB sat at bottom: calc(var(--bottom-nav-h) + 12px),
which only clears the bottom nav. When a day is selected, DayDetailPanel slides
up over the map from bottom: navh+20 and spans nearly full width at z-index
10000, covering the button's band — so the button was hidden behind it.

DayDetailPanel now publishes its live measured height to a root CSS var
--day-panel-h (ResizeObserver, reset to 0 on unmount), and both map renderers
lift the button above the panel when it's open, reusing the hasDayDetail prop
they already receive:

  hasDayDetail
    ? calc(var(--bottom-nav-h) + 20px + var(--day-panel-h) + 12px)
    : calc(var(--bottom-nav-h) + 12px)

Applied to both the Leaflet (MapView) and GL (MapViewGL) renderers. When the
panel closes, hasDayDetail is false and the offset falls back to the bottom-nav
value. Desktop is unaffected — the button is mobile-only.

Tests: new DayDetailPanel case asserting --day-panel-h is published and reset on
unmount; client tsc clean, full client suite green (2851).

* feat(mobile): make the bottom-nav "+" context-aware per trip tab (#1349)

On mobile the bottom-nav "+" always created a new place (except on the Costs tab,
where it added an expense). It now matches the active trip tab: Bookings adds a
reservation, Transports adds a transport, Costs adds an expense, and everything
else (Plan, plus tabs that have no create modal — Lists / Files / Collab) keeps
adding a place.

Follows the existing ?create=<intent> pattern: BottomNav.useCreateAction emits the
per-tab intent, and useTripPlanner consumes create=reservation|transport to open
the booking / transport modals (both already mounted at page level). Place and
expense were already wired; this just extends the mapping.

Tests: 4 new BottomNav cases (plan/bookings/transports/costs → correct intent +
navigate target); client tsc clean, full client suite green (2855).

Implements mauriceboe/TREK#1349

* [+] Unsplash

* [+] i18n

* feat(trips): download chosen Unsplash covers into uploads (#1277)

Previously a selected Unsplash photo was stored as a remote
images.unsplash.com hot-link, so covers broke offline and on link
rot. The trip PUT handler now fetches the picked image through the
SSRF guard and saves it under uploads/covers, rewriting cover_image
to the local path (502 if the download fails). Also debounces the
cover search so a slow earlier request can no longer overwrite newer
results, drops a dead userId parameter, and reverts an unrelated
vite proxy change.

* test(trips): cover the Unsplash cover download and search-race guard (#1277)

Adds unit coverage for saveUnsplashCover (host check, content-type
and size limits, download failure), the searchUnsplashPhotos error
and success paths, and the PUT handler internalising a hot-link.
Updates the existing PUT tests for the now-async handler.

* fix(docker): keep server/reset-admin.js in the build context (#1339)

The Dockerfile copies server/reset-admin.js (the admin recovery
script), but .dockerignore also listed it, so it was stripped from
the build context and the image build failed with a not-found error.
Drop the ignore entry so the COPY resolves again.

* fix(llm): stop the browser autofilling the LLM base URL (#1301)

The AI-parsing base URL and model inputs had no autoComplete, so a
browser password manager could drop the saved login email into the
base URL field. In the admin addon config onBlur then fired a model
lookup against e.g. "admin@trek.local", which the server rejected
with 400. Mark the base URL and model inputs as type=url /
autoComplete=off in both the admin addon config and the per-user
connection section.

* feat(appearance): add per-user appearance config contract

Shared AppearanceConfig (color scheme, accent, transparency, per-tier type scale, density, reduce-motion and per-device dashboard widgets) stored as one JSON blob under the existing settings key. normalizeAppearance never throws, so a malformed/partial/future blob degrades to the neutral default and can never reach the DOM. No DB migration; the default reproduces today's look exactly.

* feat(appearance): token-driven theme engine with schemes and FOUC-safe boot

applyAppearance is the single writer of styling to the DOM (the .dark class plus data-scheme/-no-transparency/-density/-reduce-motion and the custom-accent/type-scale CSS vars). An external pre-paint /theme-boot.js replays a cached snapshot before first paint and complies with the production CSP (script-src 'self'), fixing the long-standing theme FOUC. Adds seven color schemes (incl. a true high-contrast that raises neutral contrast), a custom accent with auto-derived legible text, an extended token layer (accent variants, status/shadow/overlay/inverse), a scheme-gated legacy accent bridge, and a transparency-off layer. The default scheme sets no attributes, so existing users are unaffected.

* feat(settings): appearance settings tab

New Appearance tab with color mode (moved out of Display), color-scheme swatches, a custom accent picker with a live WCAG contrast hint, transparency and reduce-motion toggles, density, a global text-size slider with advanced per-tier controls, and per-device dashboard widget toggles. Edits preview live and commit on a short debounce. i18n keys added across all locales, translated for German.

* feat(dashboard): per-device widget visibility with layout reflow

Dashboard widgets (currency, timezones, upcoming reservations, atlas and the stat tiles) can be shown or hidden independently on desktop and mobile from the appearance settings. The stat grid spreads its visible tiles to full width, and disabling the right sidebar collapses the layout to a single centered column.

* chore(appearance): add theme:lint guard for hardcoded styles

A theme:lint script (modeled on i18n:parity) flags new inline color/fontSize literals and arbitrary-hex Tailwind classes that bypass the design tokens, so future code stays themeable. Map/PDF surfaces are exempt. The token taxonomy and the six theming rules are documented in src/theme/README.md.

* fix(appearance): scale inline px font sizes so text-size reaches all content

The global text-size control only set the root font-size, which scales rem-based text (navbar, menus) but not the dense inline px sizes used across the trip planner, budget, journey and panels — so place titles and addresses stayed fixed. applyAppearance now also exposes the factor as --fs-scale-text, and a codemod wraps inline numeric fontSize in calc(<px> * var(--fs-scale-text, 1)) across components and pages (map popups and PDF excluded). Sizes are byte-identical at 100%; the control now visibly resizes the actual content.

* fix(appearance): clearer widget settings, density hint, solid surfaces with transparency off

Dashboard widget settings are grouped by where they sit on the dashboard (below the hero / right sidebar / bottom of page); the right-sidebar master toggle now nests its individual widgets and greys them out when the sidebar is off, instead of a confusing flat list mixing the master with its children. Density gains an explanatory hint plus a real compact spacing effect. Transparency-off also solidifies the Atlas glass panels and tooltip, Leaflet zoom controls and GL popups — class-based surfaces via CSS, the Atlas inline panels via a noTransparency flag.

* fix(appearance): keep i18n key parity and update the scaled-emoji test

Add the new appearance settings keys (widget group titles, sidebar/density hints) to every locale so the strict key-parity check passes, and update the single-emoji chat test to expect the now-scalable calc() font size.

* feat(appearance): granular per-size text scaling with live preview

The text-size control now adjusts each size class (Large / Medium / Normal / Small) independently as well as all-at-once. Inline px sizes are mapped to a class by their value, so the per-class sliders reach real content; each class variable = global factor x its per-class factor (no double-scaling with the root font-size that handles rem text). The settings UI gains a live preview that resizes as you drag, and the four size sliders sit behind a clear toggle.

* feat(appearance): show per-size text controls inline with examples

The four size-class sliders (Large/Medium/Normal/Small) are now always visible instead of behind a disclosure, each with a live sample rendered at that size and an example of what it affects (e.g. Normal = place names/descriptions, Small = addresses/labels).

* fix(appearance): shorten the Auto color-mode label to 'Auto' on mobile

* fix(appearance): make the dashboard hero boarding-pass solid with transparency off

* feat(appearance): mark the Readability section as experimental

Transparency-off, density and per-size typography are best-effort while the token migration is ongoing, so the section carries an Experimental badge. Adds the i18n key across all locales.

* chore(about): remove the monthly supporters section

* refactor(settings): rename the Display tab to General and group its settings

The Display tab became a catch-all once theming moved to its own Appearance tab, and its 'Display' label no longer fit. It is now 'General' (Allgemein) and split into 'Language & region' and 'Travel & map' sections. Tab labels and the new section titles are added across all locales.

* refactor(admin): group the admin sidebar tabs into sections

The admin sidebar had 11 flat tabs. PageSidebar now supports optional group headings (backward-compatible; the Settings sidebar stays flat), and the admin tabs are grouped into Users, Configuration, Integrations and Maintenance. Group labels added across all locales.

* feat(help): embed the TREK wiki as an in-app help centre

Add a Help section (profile menu, /help) that renders the GitHub wiki inside
TREK. /api/help fetches the wiki markdown — the nav from _Sidebar.md, pages,
and proxied images — from GitHub and caches it (1h TTL, serves stale on
outage), so it auto-syncs on wiki edits with no redeploy and the client never
calls GitHub directly. The page is styled to match TREK with a section
sidebar, search and react-markdown; wiki [[links]] are rewritten to in-app
routes and HTML-comment placeholders are stripped. Page state lives in a
useHelp() hook per the page pattern. Adds nav.help and a help namespace
across all locales.

* feat(auth): explain the plain-HTTP secure-cookie gotcha on login

When the server issues a Secure session cookie but the request arrived over
plain HTTP (the common LAN install over http://ip:3000), the browser drops
the cookie and the next request dead-ends on a bare "Access token required" —
the top source of avoidable install issues. The login response now flags this
exact case and the login page shows a localized box explaining the fix (use
HTTPS, or set COOKIE_SECURE=false) with a link to the Troubleshooting guide.
It only triggers in the real failure case, never for correct HTTPS setups.

* feat(costs): Splitwise-like cost splitting

Add per-payer and per-member custom split amounts with Equally, Custom and
Ticket split modes on top of the existing equal split, keep legacy "paid by"
expenses working, and document the modes in the Budget Tracking wiki page.

* feat(i18n): add Vietnamese translations

* chore(i18n): sync Vietnamese with latest dev keys

Add the keys dev gained since this PR opened so the new vi locale keeps full
parity: the help namespace (wiki help center), settings appearance options,
costs split modes, dashboard Unsplash cover search, the insecure-cookie login
hint, nav.help and the admin group labels.

* feat(helm): Add existingClaim variable for custom PVC usage.

* fix(helm): emptyDir is used as a fallback when persistence is disabled.

* docs(helm): clean up existingClaim notes

Strip stray zero-width characters from the persistence docs, move the PVC
note out of the ENCRYPTION_KEY usage block into its own Persistence section
in NOTES.txt, and document that persistence.enabled=false falls back to an
ephemeral emptyDir.

* feat(feeds): subscribable ICS calendar feeds for trips

Adds TripIt-style live calendar subscriptions alongside the existing one-time
.ics download. A trip (or all of a user's trips) exposes a secret, revocable
feed URL that Google/Apple/Outlook poll to stay in sync.

- Public read endpoints GET /api/feed/trip/:token.ics and /api/feed/user/:token.ics
  (no auth — the secret token is the credential), reusing the existing exportICS()
  generator and adding REFRESH-INTERVAL / X-PUBLISHED-TTL hints.
- JWT-guarded token endpoints to generate (lazy, idempotent) and regenerate/revoke
  per-trip and per-user feed tokens; tokens stored in nullable feed_token columns.
- All-trips feed excludes archived trips and trips ended >90 days ago.
- UI: ICS toolbar button becomes a Download/Subscribe menu; modal offers one-click
  "Add to Google Calendar" (render?cid=webcal://) and a webcal:// link for
  Apple/Outlook, plus copy-link fallbacks. All-trips feed reachable from dashboard.
- Feed base URL read from the existing APP_URL env var.

Purely additive: new endpoints + two nullable columns, no breaking changes.

Tests: server/tests/e2e/feeds.e2e.test.ts covers lazy token generate + idempotency,
regenerate-invalidates-old, 401/404 auth+access, public feed content-type + hint
injection, unknown-token 404, and the archived/>90-day all-trips exclusion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* harden calendar feeds: absolute URLs, real disable, folding, schema sync

- Resolve feed URLs against the request host when APP_URL is unset, so the
  webcal:// / Add-to-Google links work on a default install (not just behind a
  configured reverse proxy).
- Give the public link a real off switch: POST enables, PUT rotates, DELETE
  clears the token (feed_token = NULL). The subscribe dialog no longer mints a
  token just from being opened — the user opts in explicitly.
- Fold ICS content lines at 75 octets (UTF-8 safe) in exportICS, so download
  and feed both stay RFC 5545-compliant for long/non-ASCII summaries.
- Extract VEVENTs by structural line scan instead of a lazy END:VEVENT regex
  that user text could truncate.
- URL-encode the Google Calendar cid; mirror feed_token into schema.ts.
- Collapse the duplicated all-trips modal into the shared IcsSubscribeModal.

* feat(mcp): add bulk_update_places tool

Apply the same field values to many places in one call instead of one
update_place per place — e.g. re-categorising 80 POIs at once. Adds the
updatePlacesMany service (one transaction, trip-scoped, partial patch
built on updatePlace) and the bulk_update_places MCP tool with the usual
demo/access/place_edit guards and a place:updated broadcast per place.

* feat(dashboard): show the year on trip dates from other years

Trip dates only showed month + day, so trips from other years were ambiguous
(#1323). Dashboard cards and the boarding-pass hero now include the year, and
so does the shared formatDate (planner day headers etc.) — but only when it
isn't the current year, so this year's trips stay compact. Order and
punctuation follow the locale (EN "Sep 10, 2026", DE "10. Sep 2026").

* feat(places): bulk "change category" from the selection toolbar

Closes the UI half of #1168: in the Places selection mode, a new tag button
before delete opens a category picker that applies one category (or "No
category") to every selected place in a single request. Adds a REST
/places/bulk-update endpoint reusing updatePlacesMany, an offline-aware repo +
store action that patches both the place pool and the day-assignment
projections, undo grouped by each place's prior category, and the i18n keys
across all locales.

* feat(map): include the day's route in the map fit (#1128)

Selecting a day already fits the map to that day's destinations; this also
folds the route polyline into the bounds. BoundsController fits the
destinations immediately, then re-fits once — when the day's route finishes
computing asynchronously — to destinations + the full route, so a route that
bulges past its stops (a detour or ferry) stays in view. One-shot per day
selection, so later route-profile toggles don't re-zoom.

* feat(offline): detect update conflicts on the server for places and packing

Update handlers accept an optional X-Base-Updated-At token and reject a stale overwrite with 409, returning the current server row. An absent token keeps the existing last-write-wins behaviour, so older clients are unaffected. packing_items gains an updated_at column (migration + stamped on every insert) so it can take part in conflict detection too.

* feat(offline): force-offline mode, selective sync and a conflict queue

A force-offline override routes every read to the cache and every write to the queue; preparing for offline downloads trip data, documents and map tiles up front and waits for them to finish. Map tiles and individual trips can be left out of the cache. Queued edits carry the version they were based on so the queue can surface server conflicts for a keep-mine / keep-theirs decision; chained offline edits to one entity no longer conflict with each other, and evicting a trip preserves its unsynced writes.

* feat(offline): Settings -> Offline controls and a status banner

The Offline tab gains a force-offline switch, a prepare-for-offline download with progress, per-trip and map-tile storage toggles, and a conflict resolver with a default strategy. The floating status pill now reflects forced-offline and unresolved conflicts.

* i18n(offline): offline settings strings across all locales

* docs(wiki): document force-offline, selective storage and conflicts

* feat(video): media_type discriminator + local gallery video upload (server)

trek_photos gains a media_type column (migration) so the registry can hold video as well as images. A new POST :id/gallery/video endpoint accepts a video plus a client-captured poster (500 MB cap, video MIME/extension allowlist), stores the poster as the thumbnail, and the photo stream serves the poster for the thumbnail kind and the raw file (HTTP Range) for the original — without running the image thumbnailer on video bytes.

* feat(video): play local gallery videos in the journey gallery

Picking a video in the journey gallery now captures a poster frame + duration in the browser and uploads the raw clip; the grid shows the poster with a play badge and the lightbox plays it with a native video player (HTTP Range seeking). Images keep their existing HEIC-normalised path. No server-side transcoding.

Server media_type work was committed separately.

* feat(video): use Plyr for the gallery video player

Swaps the bare <video> element for a Plyr-wrapped player so playback controls match a consistent, cleaner skin. The instance is created per source and destroyed on unmount, so the lightbox stops playback when you navigate away.

* feat(video): link and stream Immich videos in the journey gallery

Immich timeline and album listings no longer filter out videos; each asset now carries its media type, which the provider picker forwards when linking. A linked video streams through Immich's transcoded /video/playback endpoint, and the asset proxy forwards the viewer's Range header (and passes 206/Content-Range back) so the player can seek. Synology video stays excluded until its stream API is verified.

Adds media_type/media_types to the provider-photos request contract.

* test(photos): assert the forwarded Range arg on the original stream

Follow-up to the Range-aware photo proxy.

* feat(video): upload and play videos in the trip file manager

The file manager (which already attaches files to a place/activity) now accepts video uploads up to the larger video cap — other types stay at the document limit — and the lightbox plays them with the Plyr player over the plain same-origin download URL, so cookie auth and HTTP Range both work. Videos are excluded from the offline blob prefetch so one clip can't evict a trip's documents.

* fix(video): harden upload handling and fix video playback edge cases

Security: the gallery-video poster is now always stored as .jpg instead of the client-supplied extension, so a poster declared image/* but named x.html / x.js can't be written with that extension and served inline same-origin; local gallery files are also served with X-Content-Type-Options: nosniff.

Robustness: rejected/unauthorised uploads no longer orphan their bytes on disk (the gallery-video and file-manager handlers unlink before throwing); the file-manager per-type size cap is keyed on the extension like the filter, so a real video labelled application/octet-stream isn't wrongly rejected. UX: the file-manager thumbnail strip shows a play placeholder for video instead of a broken image; shared (public) journeys now return media_type and play videos with a play badge; and a poster-less video shows a neutral tile instead of a broken thumbnail.

* test(video): update gallery accept selector + complete fileService mocks

The gallery upload input now accepts image/*,video/* — update the two JourneyDetailPage selectors that matched the old value. The files/journey e2e suites mock fileService and were missing the new MAX_VIDEO_SIZE / isVideoExtension / isVideoMime exports, which broke module load.

* test(video): cover the new upload-handler branches

Add controller tests for the gallery-video route (success / no-video / not-allowed / cleanup-on-reject), the per-asset media_types loops (gallery + entry, batch + single), and the file-manager per-type cap + unlink-on-rejection — restoring branch coverage on src/nest above the 80% gate.

* feat(bookings): add a dedicated URL field to reservations (#935)

Bookings get a first-class url column (migration) instead of users pasting links into notes. It's editable in the booking modal and rendered as a clickable link on the reservation card. The reservation request schemas are open passthroughs, so only the entity schema + service SQL enumerate it.

* feat(files): render uploaded Markdown files inline (#1345)

Markdown (.md/.markdown) is now an allowed upload type and opens in a rendered preview in the file manager instead of just downloading. Reuses the existing react-markdown stack with rehype-sanitize (these are untrusted uploads, so output is sanitized) and detects markdown by extension first since browsers send unreliable MIME for .md.

* feat(lists): reorder packing/to-do lists and private packing items (#969, #858)

Add drag-to-reorder to the packing and to-do lists, mirroring the budget
panel's native HTML5 drag pattern. A drag within a filtered/grouped view is
mapped back onto the global order so untouched items keep their place, and the
order persists optimistically via the existing reorder endpoints.

Packing items can now be marked private (#858): a private item is visible only
to its owner. createItem/bulkImport stamp the owner, listItems filters by the
viewer, and the WebSocket broadcasts are scoped to the owner so a private item
never reaches another member's screen — including the public/private toggle
transitions. Owners get a lock toggle and a private indicator on their items.

* feat(trips): transfer trip ownership to a member (#973)

Add POST /api/trips/:id/transfer so the owner can hand a trip to one of its
existing members. The swap runs in a transaction: the new owner takes
trips.user_id and the former owner is kept on as a regular member, so nobody
loses access. The endpoint is owner-only, writes a trip.transfer_ownership
audit entry and broadcasts the refreshed trip. The members modal gains a
"Make owner" action, shown only to the current owner.

* i18n: translate the booking link field across all locales (#935)

Fan out reservations.urlLabel / reservations.urlPlaceholder to the remaining
locales so the dedicated booking URL field is localised everywhere.

* fix(packing): drop the always-true guard in the row drag handler (#969)

The onDragOver guard `drag.isDragging || true` is a constant condition (eslint
no-constant-condition). The handler is already gated by canDrag, so run the
drag-over logic directly, matching the to-do row.

* feat(trips): guest members for accountless participants (#1362, #1291)

Add "guest" trip participants — people without a Trek account who can still be
assigned to costs, packing, to-dos and day-plan activities. A guest is a
credential-less users row (is_guest=1) joined into trip_members, so it is
assignable everywhere a real member is, with the cost-splitting, settlement,
packing and assignment paths working unchanged.

Guests are firewalled from everything account-related: they can never sign in
(password, OIDC and reset lookups skip them), never appear in the global user
directory, the member-add picker or admin user management, are never resolved as
notification recipients, can't be invited to another trip, and can't be made
owner. The trip owner manages guests from the share dialog in a dedicated,
clearly-labelled section (add / rename / remove), and guests carry a "Guest"
badge wherever members are picked. All 22 locales stay in parity.

* feat(packing): three-tier sharing — personal, shared-with-people, common pool (#858)

Rework the private-packing flag into a full sharing model. Every item is now
Common (the group pool — where all existing items live, so nothing breaks),
Personal (private to its owner) or Shared with specific people (it shows up on
those travelers' own lists, marked "by <bringer>"). is_private discriminates
restricted from common; a new packing_item_recipients table holds who a shared
item covers, and packing_item_contributors records "I can bring that too"
pledges on Common items.

The panel gains a Gemeinsam / Meine Liste view switch, each item a sharing
control (owner sets the tier + the people it covers), and Common items can be
co-brought or cloned onto your personal list. Visibility is enforced server-side
in listItems and the WebSocket broadcasts are scoped to exactly who can see an
item across every tier transition. All 22 locales stay in parity.

* style(packing): small gap between the list and the luggage sidebar divider

The luggage sidebar's left border sat flush against the right-hand category
card. Add a little left margin so the divider has minimal breathing room.

* feat(map): group GL place markers into clusters on zoom-out (#1385)

MapLibre/Mapbox showed every place as its own rich HTML marker with no
grouping when zoomed out, unlike the Leaflet map. Feed the place points
through a clustered GeoJSON source: clustered points render as a dark
count bubble (click to zoom in and expand) while the rich HTML photo
markers are only drawn for the points the source reports as unclustered.
Always on, matching the Leaflet MarkerClusterGroup.

* fix(map): match the GL place hover tooltip to the Leaflet map (#1385)

The MapLibre/Mapbox hover showed an anchored popup with a large photo
thumbnail, completely unlike the Leaflet map's slim, cursor-following
name/category/address card. Drop the anchored photo popup for places and
render the same cursor-following overlay the Leaflet map uses (no photo,
matching fonts/padding/shadow), so the two maps hover identically.

* feat(collections): backend for the Overall Places addon (#1081)

Adds the Collections addon backend: a server-wide-per-user library of saved
places, independent of any trip, with multiple named lists, an idea/want/visited
status, and Vacay-style fusion invitations to share a list with other users.

- Data: collection / collection_members / collection_places / collection_place_tags
  tables (+ migration and baseline schema). Saved places carry the owner plus a
  nullable saved_by so a member deleting their account can't drop shared content.
- Service: list + place CRUD with owner-or-accepted-member visibility, dedup,
  status, save-from-trip and copy-to-trip (reusing the trip copy column list),
  and the full fusion-invitation state machine mirrored from vacay (send / accept
  / decline / cancel / leave) with a websocket broadcast and an invite
  notification. Deleting a list snapshots its members and notifies them.
- NestJS module + addon guard (404 before auth), registered in the app module.
- Widens the place photo cache reference check to count collection places so the
  nightly sweep no longer evicts photos a saved place still uses.
- collection_invite notification wired across all 22 locales.

* feat(collections): /collections page, entry points and i18n (#1081)

Adds the client side of the Collections addon:
- A distinct /collections page (Atlas pattern, page/hook split) gated behind the
  addon: a multi-list rail, a Grid (default) / List / Map view switch, the
  idea/want/visited status with a one-tap badge, search and status filters, and
  considered empty states. Store + hook + model + websocket wiring; the place
  detail reuses the trip place inspector via a mode guard.
- Entry points: a "Save to Collection" button next to Open-in-Google-Maps in the
  place inspector (and the two sidebar context menus), a "Copy to trip" modal,
  and a desktop-only two-column add-place picker (mobile keeps the single-column
  form).
- The collection namespace and the new keys across all 22 locales.

* feat(collections): fusion sharing UI + dashboard widget + per-user toggle (#1081)

- ShareCollectionModal: the owner manages a list's members and invites users
  (available-users picker → invite, cancel pending); a member can leave a shared
  list. The incoming accept/decline surface stays in the lists rail. A Share
  button is added to the collections header for owners (and members, to reach
  Leave).
- CollectionsWidget: a dashboard glass card after the currency widget showing the
  saved count and the most recent saved places, double-gated by the admin addon
  and a new per-user appearance flag.
- Appearance: a 'collections' dashboard-widget flag (desktop + mobile defaults)
  wired into the appearance settings, surviving normalize.
- Sharing + settings strings across all 22 locales (parity strict passes).

* feat(collections): redesign the page on the dashboard glass language (#1081)

Rebuilds the /collections page from the functional placeholder into the
dashboard's glass visual language (light + dark):

- A colour-washed hero per list: eyebrow + member avatars, big title, and
  stat chips (All / Idea / Want / Visited) that double as the status filter.
- A sticky glass list rail (owned + shared + invites) with a mobile drawer.
- Gradient/photo cover place cards modelled on the trip cards, via a new
  rectangular PlaceCover (photoService-backed, gradient fallback) + a shared
  gradients util. List and map views restyled to match.
- Status pill rendered as a role=button span so it survives the .trek-dash
  button reset and can nest inside the card; the share member-count badge is
  now owner-only.
- New hero eyebrow strings across all locales.

* feat(collections): list+map split, taller rail, list-menu popover fix (#1081)

- List view splits into a scrollable list + a sticky map on wide screens;
  clicking a place pans/highlights it on the map (single selectedPlaceId, no
  inspector over the map). Narrow screens keep the single-column list.
- Keep the list rail at least as tall as the hero (measure the hero via a
  small useElementSize hook and feed its height as the rail's min-height).
- List row kebab menu: portal the menu/colour popover to the body so the
  rail's overflow + backdrop-filter can't clip it ("renders only in the
  module"), and fade the place count on hover so the kebab stops overlapping it.

* feat(collections): list+map default, map-only toggle, deselect + tooltip fixes (#1081)

- Drop the grid/tile view. The list view is now the default and, on wide
  screens, a list + persistent map split; a top-left control on the map
  collapses the list to a full-width map (and back), animating smoothly (the
  map stays mounted and is nudged to re-layout during the transition). The
  place search moves onto the map (top-right); mobile keeps a list/map toggle.
- Let a place be deselected again: clicking it once more, clicking the map
  background, or picking another all toggle the selection (collections map now
  wires onMapClick).
- Fix the stuck hover tooltip: selecting a place swaps its marker's DOM node so
  the browser never fires mouseout/mouseleave, orphaning the fixed-position
  tooltip (it hung on screen and drifted with scroll). Both map stacks now clear
  the hover on selection change and on scroll.
- Remove CollectionGrid + PlaceCover; add hero eyebrow + map control strings.

* feat(collections): list details, place detail sheet, add-place, fusion kick (#1081)

Dashboard widget (B): the collections tool now shows the user's LISTS as
compact colour-washed badges (cover image tinted with the list colour, or a
gradient) that jump to the list — one list() call, no N+1.

List details (C): lists gain a description, a custom cover image (uploaded to
/uploads/covers, tinted with the list colour in the hero) and links. A shared
ListEditorModal handles both create and edit; the hero shows the description +
link chips. New `links` JSON column on collections + collection_places
(migration 151) with parse/serialize in the service; a POST :id/cover upload
endpoint mirroring trips; cover-file cleanup path-confined locally.

Place detail (D): clicking a place opens a bottom sheet (no backdrop, so the
map stays visible) — status cycle, copy-to-trip, remove, and an edit mode with
a markdown description + links editor (collectionsApi.updatePlace, now wired
via a store action). A "+" next to the search adds a place to the list via the
maps search.

Fusion + fixes (E): the owner can now remove an accepted member (kick) — new
removeMember service/route/store + a button in ShareCollectionModal, with a
collections:removed WS bounce. findMembership no longer matches on name alone
(coordinate proximity required, killing "Starbucks everywhere" false positives).
loadCollection swallows a 403/404 after a leave/remove so the URL sync can't
throw uncaught. Grid remnants gone; the map select toggle moved onto the map.

New strings across all 22 locales; i18n parity strict passes.

* fix(collections): review follow-ups on the B–E work (#1081)

- Block the list-cover upload in demo mode (mirror the trips cover endpoint).
- Restrict list/place links to http(s) (schema) and normalise scheme-less URLs
  to https:// on save, so a bare "booking.com" no longer resolves as a relative
  SPA route (and javascript:/data: hrefs are rejected).
- Place detail: surface save errors with a toast instead of silently swallowing
  a 400 and leaving the sheet stuck in edit mode.
- List editor: don't create a duplicate list when a retry follows a cover-upload
  failure (reuse the created id); revoke the cover preview object URL.
- Map controls: one top bar (left toggle/select, right add/search) so they can't
  overlap on a narrow split map — the search shrinks instead.
- Dashboard list badge: full-opacity colour wash so the name stays legible over
  bright covers.

* fix(collections): detail-sheet, edit-refresh, map + rail polish (#1081)

- Editing a place (status, description, title, …) no longer reloads the view or
  closes the detail: the WS echo now refreshes via loadCollection, which keeps
  the current selection + select-mode instead of setActive resetting them.
- Place detail: docks over the list column on the desktop split (measured rect)
  instead of centred over the map, and the card is now opaque (was too see-through).
- Map: click a marker in full-map view to drop back to the split; picking a place
  scrolls its list row into view; the select toggle is disabled in full-map view;
  the floating controls are one non-overlapping top bar and less transparent.
- Hero: drop the New-list button (it's already in the rail).
- Rail: the kebab is always visible (easy to hit); menu is Edit + Delete only
  (colour moved into the editor); "Rename" → "Edit".
- Add-place: pick a result, then set description (markdown) / links / status
  before saving, all in one step.
- Share modal: member roster as cards with clearer role badges + a count.

* feat(collections): detail redesign + categories, close-on-map, highlight fix (#1081)

- Rebuild the place detail as a clean, opaque, sectioned sheet (cover → meta →
  status segment → description → links) with a proper footer action bar — the
  loose "white lower half" is gone.
- Assign a place to a central (admin-defined) category, both in the detail edit
  and when adding a place; categories are fetched once for the page.
- Add-place now sets category + description (markdown) + links + status in the
  same step, closer to the trip's place form.
- Switching to the full-map view now closes the (list-docked) detail.
- Fix the selected-row highlight: it was clipped by the column's overflow — use
  an inset ring and only clip during the map-collapse animation; a picked row
  now scrolls into view above the detail sheet.
- New category strings across all 22 locales.

* feat(collections): filters, add-place popup, category badges, map-click hardening (#1081)

- Map: markers no longer rebuild on every unrelated re-render (memoised the
  mappable list + only update the hero size when it really changes), the floating
  controls bar is click-through except its buttons, and the collection map runs
  with the hover tooltip off. Together these stop a marker click from landing on
  a mid-rebuild element / the tooltip so the pick actually registers.
- Filters moved out of the hero into a compact status + category dropdown row
  above the places (custom dropdowns); the hero no longer carries the stat chips.
- Add-place is a single popup now: search fills the location, and name / status /
  category / description / links are all editable together before saving.
- Category shown as a badge top-left on the detail cover and next to the status
  in each list row (divided by a hairline).
- Slimmer hero: shorter, tighter spacing, links tucked into the eyebrow row
  instead of their own line.

* feat(collections): hero edit/share row, place photos, edit-echo fix, wider page (#1081)

- Editing a place (category, status, …) no longer reloads the view: the mutating
  client's own socket is now excluded from the WS broadcast (x-socket-id threaded
  through save/update/status/delete + list update/cover), so the optimistic update
  stands on its own instead of being chased by an echoed refetch.
- Detail sheet pulls a higher-res cover photo from the maps provider when the
  place has no image of its own (the avatar thumbnail was too low-res).
- Hero: Share moved onto the title row (no more empty top band) with an Edit
  button beside it; editing/deleting a list now happens there. The list rail drops
  its per-row kebab entirely (and with it the janky open animation).
- The list editor can delete the collection from its footer (owner only).
- Wider, screen-relative page (max-width min(2100px, 95vw)).
- List rows: the place avatar no longer shrinks when the address is long.

* fix(collections): copy-to-trip labels + Unsplash cover search (#1081)

- Copy-to-trip modal showed blank rows: trips are keyed by `title`, not `name`,
  so nothing rendered. Read `title` and add the trip's date range under it.
- List editor gains an Unsplash cover search (same source as trip creation) next
  to the upload button; picking a photo sets it as the list cover.
- Add-place result rows: pin keeps a hard min width so a long address can't
  squeeze it.

* fix(collections): stop the address pin from shrinking on long addresses (#1081)

The little map pin in front of a place's address sits in a flex row with the
address text but had no flex-shrink guard, so a long address squeezed the icon
smaller. Pin the SVG to its size.

* fix(collections): white screen when editing a place (undefined in places) (#1081)

updatePlace wrote `res.place` into the places list, but the endpoint returns the
updated place directly (not wrapped in { place }, unlike savePlace) — so an
`undefined` slipped into the list and the category-filter's presentCategories()
crashed on `undefined.category_id`, blanking the whole page. The WS echo used to
mask it by refetching; excluding the editor's own socket exposed it.

- Read the updated place directly and guard against a falsy response.
- Fix the api return types to match (updatePlace/setStatus return the place).
- Harden filterPlaces / statusCounts / presentCategories / mappablePlaces against
  a stray undefined entry so a single bad row can never white-screen the page.

* feat(collections): select toolbar — select-all, move/duplicate to another list (#1081)

- The select toggle now sits at the right of the filter row (same height as the
  status/category dropdowns) instead of the top toolbar.
- Select mode gains a "select all / deselect all" toggle and shows even with
  nothing selected yet.
- Selected places can be moved or duplicated into another of your lists via a
  target-list picker (move re-points collection_id; duplicate re-saves the place
  data, carrying description / category / notes / etc.).
- New strings across all 22 locales.

* style(dashboard): accent follows the user's theme instead of a fixed orange (#1081)

The .trek-dash scope (dashboard, collections, vacay, atlas) hardcoded an orange
accent, ignoring the appearance theme. Drop the override so --accent inherits the
theme tokens (index.css): monochrome black/white by default, coloured per
data-scheme / custom accent. --accent-ink/-soft now map onto --accent-on/-subtle,
and accent-filled elements use --accent-text for legible text on any scheme.
Category colours are set explicitly per element and stay untouched.

* fix(collections): saved-places picker height + list filter, all-saved first-load (#1081)

- Trip "Saved places" picker: drop the fixed 360px cap so the list fills the
  panel instead of stopping half-way, and add list + status filter dropdowns
  (filter by which collection the place is saved in).
- "All saved" showed nothing on first open: setActive(ALL_SAVED) unioned the
  lists from the store, but on first load those aren't fetched yet (loadAll still
  running). Load them first when empty so the union isn't blank.

* test(collections): unit-test the nest controller (branch coverage) (#1081)

The collections nest module had no controller test, dragging src/nest/** branch
coverage below the 80% gate. Cover the controller's branches: reorder/deleteMany
payload validation, owner-gated invite/cancel/remove/available-users, invite +
accept error surfacing, the cover demo-mode + no-file guards, and the x-socket-id
forwarding on the mutating endpoints.

* fix(collections): mobile polish — touch targets, safe-areas, overflow (#1081)

From a mobile UX audit of the collections page:
- Detail sheet: the read-mode footer no longer clips "Remove from list" (it wraps,
  drops the growing spacer) and clears the home indicator (safe-area padding,
  84dvh instead of 84vh).
- Bigger touch targets on phones (≥40px): view toggle, filter dropdowns, select-bar
  buttons, detail close/actions, drawer rail rows, and the interactive status badge
  (enlarged tap area via a pseudo-element, look unchanged).
- Select action bar breaks its bulk actions onto their own line instead of
  stranding them behind a growing spacer.
- Lists drawer honours device safe-areas and gets an explicit close button.
- Page honours the top safe-area and goes full-width on phones (drop the 95vw cap);
  filter popovers cap their width so long category names don't overflow.
- Add-place: Cancel/Add pinned in the modal footer (reachable without scrolling),
  status pills wrap.
- Drop dead hero mobile CSS left over from the hero refactor.

* feat(collections): per-member permission roles on shared lists (#1081)

The owner now assigns each member a role — viewer (read + copy-to-trip only),
editor (default: add + edit places) or admin (full incl. delete). The owner is
always full. Existing members default to editor via migration 152, so nothing
regresses.

- Server: role column on collection_members (migration 152 + schema); roleOf +
  assertCanEdit (save/update/status/list-meta) + assertCanDelete (delete) layered
  on assertAccess; sendInvite takes a role; new setMemberRole (owner-only) +
  POST members/role; members payload carries each role.
- Client: Share modal gains a role picker on invite and a per-member role select
  for the owner (read-only role badge for others); the page hides add / edit /
  status / move / delete for roles that can't perform them (server still enforces).
- Roles in all 22 locales; service + controller tests for the new gating.

* feat(collections): bulk-add selected trip places to a list (#1081)

Add a "Save to collection" action to the trip place list's selection bar (next to
bulk category + delete): it opens a list picker and copies every selected place
into the chosen list in one request, instead of one-by-one from each place.

- Server: saveFromTripPlaces (one access check + one WS notify), POST
  places/from-trip-many; dedups by name/coords, skips missing ids, honours force.
- Client: saveFromTripMany api + SaveTripPlacesToListModal; the selection-bar
  button is gated on the collections addon being enabled.
- Copy count / skipped-duplicates toast; strings in all 22 locales.
- Service + controller tests for the bulk path.

* style(collections): custom dropdown for the permission role pickers (#1081)

Swap the two native <select> role pickers in the share modal (invite + per-member)
for the app's CustomSelect (portal dropdown, size sm) so they match the rest of
the UI instead of the browser's native control.

* style(collections): widen the share modal (#1081)

* test(collections): client component tests + select in All saved, off the map (#1081)

- Add client tests for the new collections UI (80 tests): collectionsModel (incl.
  the undefined-entry guards that fix the white-screen regression), StatusBadge,
  CollectionFilterBar, CollectionList, CollectionPlaceDetail (permission gating),
  MoveToListModal.
- Offer the select toggle in "All saved" too (server enforces per-place rights).
- Drop the now-duplicate select button from the map controls (it lives in the
  filter row).

* docs(wiki): add Collections addon page (#1081)

New wiki/Collections.md in the style of the other addon pages (lists, status,
categories, adding/bulk-adding places, place detail, filters + bulk actions,
fusion sharing with member roles, dashboard widget). Add it to the Addons
overview table + the sidebar navigation.

* feat(date-picker): add month/year drill-down navigation and keyboard input trigger

- Add three-level calendar view (days → months → years) via clickable
  header label, allowing fast navigation to distant dates without
  repeated arrow clicks
- Replace double-click text input affordance with a visible keyboard
  icon button; compact/borderless variants show the icon in the
  calendar footer
- Pre-fill text input with locale-aware numeric date (DD.MM.YYYY)
  when a value is already selected
- Add aria-label and aria-pressed to all interactive calendar elements
  for screen reader support
- Update existing tests to reflect new two-button trigger layout
- Add FE-COMP-DATEPICKER-018 through 027 covering drill-down
  view transitions, prev/next behaviour per view, aria-pressed
  state, and keyboard icon trigger

* fix(date-picker): locale-aware keyboard input parsing and i18n control labels

- Replace fixed-order date parser with locale-aware implementation
  using Intl.DateTimeFormat.formatToParts to detect field order;
  adds swap fallback for unambiguous inputs (day > 12) to handle
  locale mismatches gracefully
- Pre-fill keyboard input with locale-formatted numeric date
  (e.g. 14.06.2026) instead of raw ISO value
- Replace all hardcoded English aria-labels and titles with t()
  calls; add new keys under common.datepicker.* namespace across
  all locale files
- Update FE-COMP-DATEPICKER-013 to use unambiguous day value (> 12)
  to avoid locale-dependent test failures

* fix(date-picker): add missing locale file and fix let-to-const lint error

- Add missing common.datepicker.* keys to overlooked locale file
- Change reassigned `let` to `const` where value is not mutated
  to satisfy lint rules

* chore(i18n): backfill datepicker keys for sv + vi locales added on dev

* fix: back-merge v3.1.4 hotfixes into dev (#1371)

Port the three main-only fixes onto dev's (post-rewrite) architecture:
- fix(backups): prevent recursion when the backup path sits inside the backed-up dir
- fix(share): convert budget items to the viewer's base currency instead of a flat EUR
- fix(files): surface the descriptive server error for unsupported upload types (#1363)

Cherry-picked from 819aa793 on main; the SharedTripPage and useTripPlanner
conflicts were resolved to keep dev's font-scaling and full import set while
taking the fixes' currency conversion and translateApiError wiring.

* fix: resolve a batch of reported bugs (planner, budget, atlas, bookings, mobile)

- #1394 planner: two transports on one day no longer draw a phantom airport→airport
  road route between them (a run is only a drive when it holds a real place); mirrored
  in the map hook and the sidebar's leg list, with a regression test.
- #1392 planner: the per-day Route button now points the selection at the tapped day
  before toggling, so on mobile it computes that day's route instead of the previously
  selected one, and only the selected day's button reads as active.
- #1372 planner: the "open in Google Maps" route now includes the day's hotel bookends,
  matching the drawn map route.
- #1375 planner: a multi-day accommodation no longer thrashes the plan scroll — the
  auto-scroll lock keys on the selection identity, not the per-day row.
- #1377 planner: the reset-orientation compass is now shown on small screens too.
- #1382 budget: settlement nets in the trip's canonical currency and converts to the
  display currency once, so balances no longer drift with live FX and no phantom
  third-party micro-flows appear (identity, hence unchanged, when they're the same).
- #1366 atlas: countries reached only by a transport booking (no lodging/place) now
  count as visited, on both the dashboard and the Atlas page.
- #1383 bookings: a hotel linked to an accommodation shows only its day-range, not a
  duplicate stamped date row, and the range stays correct after an edit.
- #1353 bookings: any non-hotel reservation can now link an existing trip place/activity.
- #1390 i18n: fix the Polish word for "buddies" (Towarzysze → Współpodróżnicy).
- #1265 planner: drag-and-drop of places now works on touch devices via a polyfill.

* feat(planner): add an "Open in OpenStreetMap" button to the place inspector

Next to the existing "Open in Google Maps" action, add an OpenStreetMap button that
opens the place on openstreetmap.org (a marker at its coordinates, or a name search
when it has none) — the same map source TREK already renders, and a jumping-off point
for OSM-based apps like OrganicMaps / CoMaps. Requested in discussion #880.

Strings across all 22 locales; a unit test for the URL builder.

* feat(planner): shorten the map-open button labels to "Google Maps" / "OpenStreetMap"

* feat(planner): show a day's route distances inline on mobile

Seeing the driving/walking distances between a day's places on mobile
meant tapping the day (which closes the plan sheet), reopening it, then
tapping Route. Now the per-day Route button in the mobile footer toggles
that day's leg distances in place, so the sheet stays open and you get the
distances between places without selecting the day first.

The leg computation runs for every route-toggled day instead of only the
selected one, and the leg/hotel-bookend maps are nested per day so several
toggled days can't overwrite each other's segments. Desktop is unchanged.

Discussion #1374

* feat(oidc): use the picture claim as avatar when none is uploaded

When a user signs in via OIDC and hasn't uploaded a custom avatar, their
`picture` claim is now used as their avatar. The users.avatar column holds
either an uploaded file name or an absolute https URL from the claim, and a
single resolver on each side (server avatarUrl, client avatarSrc) renders
both. An uploaded avatar always wins and is never overwritten; the picture
refreshes on each login otherwise. Only https URLs are stored, matching the
image CSP.

All the scattered /uploads/avatars/ builders now go through the resolvers,
which also fixes collection member avatars that were rendering a bare file
name.

Discussion #1399

* feat(trips): trip invite links + optional trip binding on admin invites

Trip invite links (#1143): each trip can have one rotating invite link in its
Share panel. An existing, logged-in user who opens /join/<token> is added to
the trip as a member; an anonymous visitor is sent to the login page and
returned to the invite afterwards — there is no registration from this link.
Reading, rotating or disabling the link all require the share_manage permission.

Admin invite trip binding (#1402): the admin create-invite dialog can now bind
a registration invite to a trip. Someone who registers via that link is
auto-added to the trip as a member (password and OIDC paths), inside the same
atomic step that consumes the invite.

Adds a trip_invite_tokens table and a nullable invite_tokens.trip_id, a shared
owner-safe/idempotent add-by-id helper, the manage + join endpoints, the
JoinTripPage and Share-panel section, the admin trip picker, and the new i18n
keys across every locale. Wiki updated.

Discussion #1143

* fix(join): extract JoinTripPage state into a useJoinTrip hook

The page container held useState/useEffect directly, tripping the CI
page-pattern check. Move the token preview + accept logic into a co-located
useJoinTrip() hook; the page is now a thin presentational shell.

* feat(costs): filter expenses by category and by a single day

Adds two filter dropdowns next to the Search Expenses field (height-matched
to it): one filters by expense category, the other narrows to a single day.
Selecting a day shows a prominent summary banner with that day's total, and
hides the now-redundant per-day header. Both filters work on the desktop and
mobile layouts and compose with the existing search + all/mine/owed filters.

New i18n keys (costs.filter.allCategories / allDays, costs.expensesCount)
across every locale.

* fix(admin): use TREK's CustomSelect for the invite trip picker

The "add to trip" dropdown in the admin create-invite dialog was a native
<select>; swap it for the shared CustomSelect so it matches the rest of the UI
(searchable once there are many trips).

* feat(planner): public transit routing via Transitous (#1065)

Each day header gets a transit button (replacing the rename pencil, which
moved next to the day name in the day detail panel). It opens a route search
backed by Transitous/MOTIS — free, open data, no paid provider: from/to stop
search with the day's own places as quick picks, depart/arrive time, mode
filters (train, subway, tram, bus, ferry, cable car) and ranking by best
route, fewer transfers or less walking. Results show local times, duration,
transfers, walking time and line badges in their official colors, with a
stop-by-stop breakdown per connection.

Adding a connection saves it as a regular transport reservation — typed by
its dominant leg, timed from the itinerary's wall-clock departure/arrival
converted to station-local time (tz-lookup), with the origin, transfer stops
and destination as endpoints and the compact legs in metadata.transit. It
slots into the day timeline by time and inherits editing, deletion and
drag-reordering from the existing transport machinery; the transport detail
view renders the full itinerary. Re-saving a transit transport through the
edit modal preserves the stored itinerary while the route is unchanged.

The server proxies the Transitous API (JWT-guarded, rate-limited, identifying
User-Agent, short response cache, strict mode whitelist); TRANSIT_API_URL
lets self-hosters use their own MOTIS instance. New i18n keys in every
locale, wiki page updated.

Discussion #1065

* fix(build): declare tz-lookup as a client dependency

It was present in the lockfile but undeclared, so the local install had it
while the Docker client build (npm ci --workspace=client) did not.

* test(maps): add buildUserAgent to the mapsService mock

transitService imports it at module load, and the full-app integration boot
now pulls the transit module in — the factory mock lacked the export.

* feat(planner): make transit journeys first-class entries (#1065)

A saved transit route is now its own reservation type instead of piggybacking
on train/bus: it gets a tram icon and its own color everywhere, and the day
timeline renders the itinerary inline — line badges in their official colors
with walk segments, plus the transfer count and walking time — instead of a
generic transport row.

Clicking the row opens the itinerary view (journey summary, stop-by-stop legs
with times, platforms, headsigns and operators) rather than the edit form;
editing stays reachable from an Edit action inside that view. The transit
type is registered across the timeline merge, transport modal, reservations
panel, file manager, map overlays and detail panels, with a translated type
label in every locale.

* feat(planner): integrate transit into the transport system as Automated mode

The add-transport dialog gains a Manual/Automated switch: Automated embeds the
public-transit search (day picker + from/to + modes + preferences + results)
right in the dialog, and the day header's tram button opens it directly in
that mode. The standalone search modal is gone.

Saved journeys get their own roomy journey view — the stop-by-stop itinerary
(times, platforms, lines, headsigns, operators) together with the editable
booking fields, delete, and a "Change route" action that re-runs the search
pre-seeded with the journey's origin/destination and replaces the itinerary on
save. The generic transport form no longer opens for transit entries, from the
timeline or from the Transports tab, where journeys now sit in their own
"Automated public transit" section with their line badges on the card.

New i18n keys in every locale; wiki updated.

* feat(planner): polish the transit journey UI and fix its tab placement

Transit entries were classified as bookings by the planner's transport-type
list and landed in the Bookings tab — they now sit in the Transports tab's
own section, rendered as proper journey cards (tram icon, arrow title, leg
chips, journey stats) instead of the generic booking card.

The journey modal got a redesign: the title renames inline in the header
with an icon arrow, the stats become three full-width tiles (duration /
transfers / walking, each with an icon), status and booking-code fields are
gone, and notes take the full width with a markdown write/preview toggle.
The Automated search mode gains a proper header (icon, hint, day picker)
and the day-plan row now shows walks with their minutes inside the chip
sequence (🚶 3 › U2 › 🚶 3) instead of a detached direct/walk summary.
"A → B" titles render with an arrow icon everywhere. The Transports tab's
add button is simply "Transport" now that the dialog covers both modes.

* test(nav): the bottom-nav add button is labelled Transport now

* feat(planner): markdown toolbar for journey notes + calmer transit search form

The journey notes gain a proper markdown toolbar (bold, italic, strike,
heading, list, checklist, link, code) that wraps the selection or prefixes
the current lines. The transit search options settle into one calm card:
depart/arrive + time + date and the ranking preference share the top row,
the mode filters and the search button share the bottom row, with the mode
chips restyled from heavy filled pills to quiet toggles. The day-plan row
drops the transfer count — the leg chips already tell the story.

* feat(planner): badge meta rows + inline itinerary expansion for transit

Dot-joined meta text becomes quiet badge chips everywhere transit facts are
listed: the journey modal's per-leg line (time, duration, stops, headsign
with an arrow icon, operator de-emphasised), the search results' leg details,
and the Transports-tab journey card (day, date, time span, duration — the
transfer count is gone from the card).

The day-plan transit row swaps the map-connections toggle for an expander:
the chevron folds the stop-by-stop itinerary out right inside the timeline —
times, line badges, stations with platform and stop counts — sized for the
sidebar.

* feat(planner): walk legs as centred dividers + journey-card note line

Walk segments in the journey modal and the day-plan inline itinerary
collapse from two lines into a single centred divider — dashed rules left
and right, the walk in the middle (foot icon, destination, minutes). Leg
meta badges sit tighter under their titles. The Transports-tab journey card
shows a dimmed first-line note preview, and the journey modal now reads the
reservation from the live store, so an update is visible the moment the
entry reopens.

* feat(map): draw transit journeys along their real rail and bus alignments

MOTIS leg geometry (encoded polylines) now travels through the proxy and is
stored per leg, so both map renderers draw the journey along the actual
tracks instead of a straight line: colored cores in each line's GTFS color
over a white casing, walks as dotted grey connectors. Transit journeys are
always visible on the map — they are part of the plan itself, not an opt-in
overlay — and the day route already anchors to their stations, so the
journey slots into the route computation end to end. Entries saved before
this keep the straight-line fallback.

Also: stronger dashes on the walk dividers, notes open rendered (preview
tab) when present, and MOTIS's START/END placeholders are replaced with the
places the user actually picked.

* fix(planner): elegant walk-divider hairlines + proven notes preview

The walk dividers switch from dashed borders to 1px hairlines that fade
towards the outer edges — strongest next to the walk text. A regression
test pins the journey modal opening existing notes on the rendered
markdown preview rather than the raw text.

* fix(map): transit polish — earlier label collapse, route-toggle coupling, md note preview

Station badges on transit journeys collapse to icon dots much earlier when
zooming out (label threshold 900px instead of 400). The drawn transit paths
now ride the day-route toggle: turning the route off hides them too, since
they are part of the computed route. The journey card's note preview renders
its first line as inline markdown instead of raw asterisks.

* fix(settings): booking route labels default to off

The map endpoint labels only render when the user explicitly enables them;
an unset preference now means hidden, matching the calmer default the
transit paths brought to the map.

* style(settings): TREK-styled text-size sliders

The appearance tab's native range inputs become proper TREK sliders: a thin
pill track filled up to the current value in the accent color, with a soft
round thumb that scales slightly on hover/drag.

* fix(planner): mobile layouts for the transit popups

The journey modal and the transit search now lay out properly on phones:
from/to stack vertically with the swap rotated between them, the ranking
segment and search button go full width, the day picker in the automated
header spans the row, the three stat tiles compress to centred mini tiles,
the itinerary tightens its gutters, and the footer wraps with an icon-only
delete. Desktop is unchanged.

* fix(planner): tighter mobile transit search + vertical journey itinerary

* fix(planner): wrap-safe mobile itinerary text — platform below the stop, minutes-first walks

* feat(plugins): plugin system scaffold — registry tables + admin panel

First slice of the plugin system. Lays down the data model and a read-only
admin surface; nothing executes yet.

- Migration 155: plugins, plugin_meta_migrations, plugin_error_log and
  plugin_settings_fields tables. Plugin data will live in a per-plugin sqlite
  file under /plugins-data, never in these tables.
- New Nest module server/src/nest/plugins with GET /api/admin/plugins
  (admin-gated, returns the installed list + the runtime-enabled flag).
- TREK_PLUGINS_ENABLED kill switch (config.pluginsEnabled), off by default.
- Admin → Plugins tab with a read-only panel: installed list, status badges,
  and a clear banner when the runtime is disabled by server config.
- i18n keys for the tab and panel across all locales.

Install, activation, the isolated runtime and the registry browser follow in
later slices.

* feat(plugins): isolated per-plugin runtime + capability RPC (M1)

Every plugin now runs in its own forked child process with a scrubbed env
(no JWT_SECRET, no db path, nothing inherited). It talks to TREK only over a
JSON-RPC channel, and the host's capability router registers ONLY the methods a
plugin's granted permissions unlock — so an ungranted call is unreachable, not
merely refused. The plugin's own data lives in a separate sqlite file it can
never open directly; core reads (trips/users) go through membership-checked,
column-projected host methods; ws broadcasts are force-namespaced.

- protocol/envelope: the wire types + method→permission map (pure, shared by
  host and the isolated child)
- host/rpc-host: the capability router = the enforcement point (dispatch,
  BAD_PARAMS / PERMISSION_DENIED / RESOURCE_FORBIDDEN / UNKNOWN_METHOD)
- host/plugin-data: the per-plugin sqlite file (db:own), guarded against
  ATTACH/PRAGMA escape, idempotent migrations
- host/create-rpc-host: wires the router to the real db/websocket (host-only)
- runtime/plugin-sdk + plugin-host-entry: the child bootstrap + definePlugin
  ctx; turns each ctx call into an RPC, never imports a privileged module
- supervisor: spawn on activate, heartbeat/reap, crash backoff + auto-disable,
  graceful shutdown — a plugin crash/OOM/hang can never reach the Nest loop
- paths: code/data layout, dist-vs-tsx child entry resolution

Nothing is wired into activation yet (that's the next slice); exercised by unit
tests for the router/sdk/data and an integration test that forks a real child.

* feat(plugins): activation, HTTP route proxy + instance settings (M2)

Wires the isolated runtime into TREK. Admins can now activate a plugin from the
panel and its HTTP routes work end to end, still behind the kill switch.

- PluginRuntimeService owns the supervisor: activate spawns the child with its
  granted permissions + decrypted instance config, deactivate kills it, status
  and errors are persisted to the plugins / plugin_error_log tables, and active
  plugins are booted on startup (OnModuleInit).
- Bidirectional RPC: the child now handles host→child invokes (routes/jobs) and
  reports its declared routes on load; the supervisor gained invoke()/routesOf().
- /api/plugins/:id/* proxy controller — a single static route that matches the
  plugin's declared routes, enforces per-route auth (auth:false routes are public
  for OAuth callbacks/webhooks), forwards only a whitelisted request view (never
  the session cookie), and strips unsafe response headers.
- Admin endpoints: POST :id/activate, POST :id/deactivate, GET/PUT :id/config —
  instance settings with secret fields encrypted (apiKeyCrypto) and masked.
- Kill switch moved to its own module so it never collides with test config mocks.

Photo/calendar hook consumers are deferred to a later slice.

* feat(plugins): sandboxed page/widget frames + trekBridge (M3)

Plugins can now render UI. Page plugins appear as a nav entry and open a
full-page sandboxed iframe; the frame talks to TREK only over the postMessage
bridge.

- Server serves plugin client assets at /plugin-frame/:id/* with a strict path
  guard and a locked-down, per-plugin CSP (default-src none; connect-src limited
  to declared outbound hosts; sandbox WITHOUT allow-same-origin -> opaque origin,
  so the frame can't read the session cookie or the parent DOM). Global CSP
  frameSrc relaxed from 'none' to 'self' for exactly these frames.
- GET /api/plugins feed lists active plugins for the client.
- Client: pluginStore + PluginFrame (the trekBridge host) authenticates every
  inbound message by SENDER WINDOW IDENTITY (event.source), not by a claimed id
  or origin; pushes context (theme/locale/tripId/userId), validates navigation,
  renders notifications as text, resizes widgets, and proxies trek:invoke to the
  plugin's own routes host-side (session cookie stays with the host).
- Page route /plugins/:id + Navbar nav injection for page plugins.

Dashboard widget slot and the trek:request core-data bridge are deferred to a
later slice.

* feat(plugins): secure installer — manifest, discovery, safe extract/fetch/scan (M4)

Plugins placed on the /plugins volume are now discovered, validated and registered
as inactive, ready to activate.

- manifest.ts: strict trek-plugin.json validation (id/version/type, known
  permissions only, egress required with http:outbound, native modules rejected).
- discovery.ts: scans the volume on startup + on demand (POST /api/admin/plugins/
  rescan), upserts rows INACTIVE, refreshes settings-field descriptors, and never
  downgrades or wipes an already-installed plugin's status / grants / config.
  Invalid or native-carrying plugins are skipped and logged.
- Activation now grants the DECLARED permissions (the consent gate) and persists
  them before spawning.
- install/ utilities for the registry installer (M5), each independently tested:
  - safe-fetch: host allowlist (GitHub only) + private-IP refusal + manual
    redirect following + size cap + sha256 (constant-time compare).
  - safe-extract: zip/tar-slip-safe extraction with its own minimal tar.gz + zip
    readers; rejects traversal, absolute paths, symlinks, oversized/too-many
    entries, and unsupported formats.
  - native-scan: refuses .node / binding.gyp / prebuilds, never follows symlinks.

* feat(plugins): TREK-side registry — browse + one-click install (M5)

Connects TREK to the static GitHub registry (mauriceboe/TREK-Plugins). The
registry repo + CI gates were already live; this is the server side.

- registry.service: fetches the single aggregated dist/index.json (never
  per-plugin GitHub API calls — the HACS rate-limit lesson), caches it 30 min,
  soft-fails to a stale/empty registry, and installs a pinned version through
  the M4 pipeline: safe download -> sha256 verify -> slip-safe extract ->
  manifest re-validate -> native re-scan -> atomic move -> discover (inactive),
  recording repo/commit/sha provenance. Handles the codeload {repo}-{sha}/
  wrapper directory.
- Admin endpoints: GET /api/admin/plugins/registry (browse metadata) and
  POST /api/admin/plugins/install { id, version }. Install never executes code;
  activation stays a separate, deliberate step.

* feat(plugins): trek-plugin-sdk package — types, mock host, scaffolder, validator (M6)

The author-facing SDK, a standalone dependency-free package (not wired into the
app workspaces, so it can't affect the app build).

- definePlugin + the full plugin type surface (PluginContext, PluginRoute,
  PluginJob, PhotoProvider, CalendarSource) mirroring what the isolated runtime
  injects; PLUGIN_API_VERSION.
- createMockHost (trek-plugin-sdk/testing): a PluginContext that enforces the
  SAME permission model + membership checks, so authors can unit-test that their
  plugin degrades gracefully — no running TREK needed.
- validateManifest: the exact rules the registry CI runs, so a local pass
  predicts a CI pass.
- CLIs: create-trek-plugin (scaffolds a working plugin + README + starter iframe)
  and trek-plugin validate (manifest + README sanity).

Consolidating the server loader to import this shared validator is a follow-up.

* docs(plugins): plugin wiki + reference plugin (M7)

- Wiki pages (sync to the GitHub wiki on push to main): Plugins overview + trust
  model, Plugin Development (SDK, definePlugin, ctx, routes/jobs, the client
  bridge, testing with the mock host), Plugin Permissions reference, and
  Publishing (registry PR + CI gates + provenance). Linked from the sidebar.
- Reference plugin plugin-sdk/examples/trip-countdown: a complete, minimal-
  permission widget (reads trip data through ctx, renders in the sandboxed
  iframe via the bridge, filled-in README). Validated in the SDK test suite so it
  passes the exact gate authors face.

* feat(plugins): lifecycle polish — uninstall, error log, egress guard, widget slot (M8)

- Uninstall with data disposition: POST /api/admin/plugins/:id/uninstall kills the
  plugin, removes its code + DB metadata, and (deleteData) drops its data dir,
  error log and per-user settings.
- Error log: GET/DELETE /api/admin/plugins/:id/errors — the plugin's own crash /
  request-failure log, surfaced in the admin panel.
- Egress guard: the isolated child wraps global fetch and refuses any outbound
  host not in the plugin's declared egress[]; with none declared, all outbound is
  blocked. Process-level defense in depth (the container runtime enforces it at
  the network layer in v2).
- Admin → Plugins is now actionable: activate / deactivate / uninstall, a
  registry browser (install), and per-plugin error log. i18n across all locales.
- Dashboard widget slot: active widget plugins render as sandboxed cards.

The trek:request core-data bridge + photo/calendar hook consumers remain follow-ups.

* docs(plugins): clarify the fork-and-PR publishing flow

* fix(plugins): allow GitHub's rotating release-asset host in the installer

GitHub 302-redirects release-asset downloads to a rotating *.githubusercontent.com
host (objects / github-releases / release-assets). The SSRF allowlist only had
objects.githubusercontent.com, so installs failed with 'host not allowlisted'.
Allow the whole *.githubusercontent.com suffix (plus github.com/codeload); the
private-IP check remains the SSRF backstop.

* fix(plugins): allow inline scripts in the sandboxed frame + fix server lint error

- Plugin frame CSP: the frame runs at an opaque origin (sandbox without
  allow-same-origin), so script-src 'self' matches nothing and the widget's own
  script never runs (stuck on 'Loading…'). Allow 'unsafe-inline' — the sandbox,
  not this directive, is the isolation boundary, and the plugin author controls
  the frame code either way.
- Fix a no-constant-binary-expression eslint error in registry.test.ts that was
  failing the server lint:check (eslint .) in CI.

* fix(plugins): exclude /plugin-frame/ from the service-worker navigate fallback

The PWA service worker's navigateFallback served the SPA shell for any
navigation not on its denylist. /plugin-frame/ wasn't listed, so the SW
intercepted the sandboxed opaque-origin plugin iframe navigation, which Chrome
reports as 'Unsafe attempt to load URL … from frame with URL …'. Denylist it so
plugin frames are served straight from the network.

* feat(plugins): pass the dashboard's spotlight trip id to widget plugins

Widget plugins now receive the current (spotlight) trip id in their bridge
context, so a widget like Trip Countdown can show a real countdown instead of
the empty state.

* fix(plugins): trips.getById returns the actual trip row, not the access check

canAccessTrip only returns { id, user_id } (it's a membership check), but the
rpc-host's trips.getById returned it verbatim — so plugins saw a trip with no
title/start_date/etc. Fetch the real row after the access check. Also fix the
reference plugin to read t.title (the trips column is 'title', not 'name').

* feat(plugins): persist enable-intent across restarts + redesign admin page

The deactivation-on-deploy bug: `status` conflated the admin's ON/OFF intent with
runtime health, so a boot crash flipped status to 'error' and the plugin never
rebooted after the next deploy. Migration 156 adds an `enabled` flag (admin
intent) separate from `status` (runtime health); boot now retries every enabled
plugin regardless of last status, and a crash no longer erases the intent.

Admin → Plugins redesign:
- ON/OFF is a ToggleSwitch bound to `enabled`; runtime health shows separately as
  a coloured status dot, with the last error inline when it crashed.
- "Update → vX" badge when the registry has a newer version (one click updates
  and reactivates).
- Reviewed/unreviewed trust badges, cleaner cards, nicer empty state, registry
  browser marks already-installed plugins. i18n across all locales.

* fix(plugins): backfill enabled for any plugin not explicitly deactivated

status at migration time can be error/stopped/starting after a crash or shutdown,
not just 'active' — so backfill enabled=1 for everything except 'inactive' (the
only status deactivate() sets).

* feat(plugins): hero widget slot + Koffi reference plugin

Widget plugins can now declare capabilities.widget.slot 'hero' to render as a
transparent, click-through overlay sitting on the boarding-pass bar's top edge
(migration 157 persists capabilities; manifest validation server+SDK, feed
exposes the slot, dashboard mounts hero frames above the pass). Sidebar stays
the default slot.

Replaces the trip-countdown example with Koffi, the TREK mascot: an animated
suitcase with a 14-state behavior engine driven by real trip data — walking,
waving, napping, trolley rolls, passport-stamp stickers, a split-flap luggage-
tag countdown under 7 days, and sunglasses while the trip runs. Validated by
the SDK suite like any author plugin; published as mauriceboe/trek-plugin-koffi
in the registry.

Migrations 156/157 follow the idempotent ALTER pattern (the reconciliation
test re-runs everything from v135, so duplicate-column must stay non-fatal).

* feat(plugins): richer admin panel + registry detail view

Admin list: flush-left header like Addons, type/reviewed badges, runtime
health as a dot on the icon tile (text badge only for problem states),
description + source-repo link on installed cards, manifest icon.

Browse: cards show the plugin screenshot (docs/screenshot.png at the pinned
commit) and open a detail dialog fed by GET /api/admin/plugins/registry/:id —
live-manifest permissions in plain language, egress hosts, setup preview,
repo/homepage links. Manifest fetched server-side through safeDownload at the
reviewed commit, cached per plugin for 30 min and only when a detail opens.

Also fixes the update flow (restart the running child around the install,
keep the admin's enabled intent instead of force-activating disabled
plugins), guards the icon lookup against Object.prototype names, reserves
ids that would shadow static admin routes, stops negative-caching failed
manifest fetches, and makes the version compare prerelease-safe.

* i18n: localize the plugins admin section across all locales

The admin.plugins block was still English filler in most locales; translate
it everywhere and add the new detail-view keys in all 22 languages.

* feat(plugins): denser browse grid + prominent install-risk disclaimer

Four cards per row on desktop with tighter card padding, and a full-width
warning banner above the browse grid: installs are at the admin's own risk,
a prior quick review does not rule out harmful content, inspect a plugin
yourself when in doubt — TREK accepts no responsibility. All 22 locales.

* feat(plugins): sandbox hardening — OS permission model, egress choke point, bound acting user, author signatures

Closes the four gaps the security review surfaced:

- OS permission model on the prod plugin child (Node --permission with
  fs-read scoped to the compiled server dir + the plugin's own code dir, no
  fs-write/child_process/worker/native). A plugin can no longer read trek.db
  or the .jwt_secret/.encryption_key files, nor shell out — the direct-fs and
  RCE escapes that bypassed the RPC layer. Opt-out via TREK_PLUGIN_PERMISSIONS=off.
- Egress guard extended from fetch to the net.Socket connect choke point, so
  node:http/https/net/tls obey the declared-egress allowlist too (no declared
  egress = no outbound). Under the permission model there is no clean escape to
  an unwrapped runtime. Kernel/network-namespace containment remains the
  container step.
- Trip reads are membership-checked against the acting user the HOST binds from
  the authenticated invocation, not an asUserId the plugin supplies; a job/onLoad
  (no user) can't read user-scoped trips.
- Optional minisign (Ed25519) author signatures verified offline, TOFU-pinned
  (migration 158). Unsigned plugins install on sha256 alone; a signed plugin
  can't silently drop its signature or swap its author key.

Server suite green (permission-model activation verified against the Koffi
reference plugin on dev1).

* fix(plugins): make the permission-model child load from the real plugin path

The prod data dir is a symlink (server/data -> volume), so resolving the plugin
under it tripped the permission model, and Node's module-type lookup walked up
into the (denied) data dir. Fork the child from the plugin's realpath and drop a
{"type":"commonjs"} package.json at its root so resolution stops there — trek.db
and the secret files stay unreadable, verified against Koffi.

* test(plugins): cover pluginRealCodeDir fallback + ensurePluginModuleType

* feat(plugins): zero-config L1 hardening — SSRF egress, RSS reaper, capability audit, no popups

Security that ships from the install itself, no self-hoster setup:

- Egress SSRF/rebinding backstop: the net.Socket connect guard now RESOLVES the
  destination and refuses private/loopback/link-local/metadata/CGNAT/ULA
  addresses, pinning the resolved IP (a declared host that re-resolves to an
  internal address is blocked). Pure policy in egress-policy.ts + tests.
  TREK_PLUGIN_ALLOW_PRIVATE_EGRESS=on opts back into internal targets.
- RSS memory reaper: the supervisor now kills a child that blows a real RSS
  ceiling (TREK_PLUGIN_MAX_RSS_MB, default 300) — --max-old-space-size only
  bounds the V8 heap, so Buffers could OOM the box under it.
- Hash-chained capability audit log (migration 159): every core-data / broadcast
  call is recorded at the RPC boundary with the host-bound acting user and a
  per-plugin hash chain, so wide grants stay attributable + tamper-evident.
  Admin endpoint GET /api/admin/plugins/:id/audit.
- Drop allow-popups from the plugin frame (sandbox + CSP): window.open ignores
  connect-src, so it was an egress/phishing bypass.

Server suite 207 green, client + migration reconciliation green.

* fix(plugins): close the UDP + DNS egress hole in the network guard

The egress guard only wrapped fetch and net.Socket.connect, so TCP and
HTTP were contained but two channels stayed wide open: a plugin could
send data out over UDP (node:dgram) or tunnel it inside DNS queries
(dns.resolveTxt & friends) to any host it never declared. Neither goes
through net.Socket.connect, so the allowlist never saw them.

Wrap both now against the same declared-host allowlist:
- dgram send/connect: the explicit destination is allowlisted and
  private-IP-checked like a TCP connect (a null address keeps the
  connected/localhost default, which the connect wrapper already vetted).
- the dns resolver family (module fns, dns.promises, Resolver.prototype):
  a forward lookup for an undeclared name is refused, which kills DNS
  tunnelling even when no socket is ever opened.

A plugin with no declared egress now really has no way out.

* feat(plugins): re-consent gate when an update wants new permissions

Updating a plugin used to just reinstall and reactivate, which silently
granted whatever the new version declared — so a plugin could quietly
widen its own rights on the next release.

Route updates through a new server-side update() that diffs the new
version's declared permissions against what the admin already granted:
- nothing new -> the plugin is restarted transparently on the new code.
- new permissions or a new outbound host -> the new code is installed
  but the plugin is left OFF, and the delta is handed back so the admin
  has to approve it before it turns on.

Install runs first, so a failed download/signature check leaves the
running plugin untouched. The client shows the delta in a consent dialog
and only then activates. An update can never widen a plugin behind your back.

* feat(plugins): honest security info in the admin panel + update consent UI

Reworks how the plugins panel talks about safety, since the old copy
oversold it. Drops the "install at your own risk" banner and the generic
trust note, and replaces them with:

- a collapsible security section that lays out plainly how a plugin is
  contained, what the permissions actually mean (a hard limit on what a
  plugin CAN do, not a promise of what it does), where the limits are,
  and what a hostile plugin could do at worst.
- a short note on what "Reviewed" means: a maintainer scanned it for
  malware each version, not for quality — not a guarantee it's harmless.
- the consent dialog for the update flow: when an update asks for rights
  you never granted, it lists the new permissions and outbound hosts and
  makes you approve before the plugin turns back on.

Full copy in all 22 locales.

* feat(plugins): redesign the admin plugins page — search, filters, cleaner cards

The panel was cramped and hard to scan. Rebuilt it as a proper management
surface:

- A segmented Installed/Discover switch with counts, and a real toolbar:
  search, filter by type, filter by status (active/off/update/error), and
  sort (name/recent/updates first).
- An "N updates available · Update all" bar.
- Installed rows are tidied up: a single health dot on the icon tile
  instead of a wall of badges, and capability chips underneath that show
  what each plugin can actually reach at a glance (reads your trips,
  dashboard widget, the hosts it talks to) — the reach is now visible
  without opening anything. Update, toggle and a ⋯ menu (restart, errors,
  source, uninstall) sit on the right.
- The registry browser is now an App-Store-style card grid: screenshot
  with the plugin's icon chip, a reviewed badge, consistent heights.
- The detail dialog gained "What it can access", "Connects to" and a
  details grid (version, size, requires, reviewed).

To feed the capability chips, the installed list now returns each plugin's
declared permissions and capabilities. New copy is in all 22 locales.

* feat(plugins): make the plugins admin page work on small screens

The redesign was built desktop-first. On a phone the toolbar wrapped into
a mess and the rows were too cramped. Reworked the responsive behaviour:

- The toolbar stacks on mobile — tabs + rescan on top, full-width search,
  then a right-aligned filter row — and collapses back into one row on
  sm+ (via display:contents), so the desktop layout is unchanged. Filter
  buttons drop their label on mobile and lead with an icon; their menus
  are capped to the viewport width so they never push the page sideways.
- Installed rows use tighter spacing on mobile and the update button
  shrinks to just its icon (full label from sm up).
- Horizontal padding, the discover grid and the detail dialog all get
  mobile-friendly spacing.

* fix(plugins): make the detail dialog screenshot fill the full width

aspect-[16/9] together with max-h-64 made the browser shrink the image
width to keep the ratio once the height was capped, leaving a grey strip
on the right. Drop the max-height so the header image spans the dialog.

* feat(plugin-sdk): one-command publishing — pack, entry, release

Publishing a plugin meant hand-building the zip, running shasum + stat,
resolving the tag's commit, and hand-writing the whole registry entry.
The SDK does all of it now:

- `trek-plugin pack` builds plugin.zip in the exact layout the installer
  reads (own tiny zip writer, so the SDK stays dependency-free and the
  format can't drift from the reader), enforces the same native-binary and
  size rules, and prints the sha256 + size. docs/ is left out — the store
  fetches the screenshot from the repo, so it doesn't belong in the install
  artifact (Koffi's went from 943 KB to 15 KB).
- `trek-plugin entry` emits the ready-to-PR registry entry from the manifest
  + the packed zip + the git tag: commitSha (deref'd), downloadUrl, sha256,
  size, and minTrekVersion derived from the manifest's trek range. `--merge`
  prepends a new version onto an existing entry for updates.
- `trek-plugin release` chains pack → gh release → entry.

Also: the scaffold now points the README at docs/screenshot.png (the path
the store actually fetches, was screenshot-1.png) with a size hint, and
stops hard-coding an MIT license — a plugin is the author's own code under
their own license. Round-tripped against the real server extractor; 18 tests.

* docs(plugins): rewrite the plugin wiki against the current code + tooling

The plugin wiki had drifted from the app and the SDK. Rewrote all four
pages, verifying every command, permission, field, path and UI behaviour
against source:

- Plugins: activation is a toggle (no separate consent screen); install is
  the Discover tab (no "Browse plugins" button); you review permissions in
  the detail modal before installing; documents update + re-consent, the ⋯
  menu, toolbar filters, capability chips and the health dot.
- Plugin-Development: full manifest reference; ws:broadcast:trip/:user (there
  is no ws:broadcast:*); onLoad + onUnload; the trek:error bridge message and
  full context payload; trips.* only work in a route handler; asUserId is
  accepted-but-ignored; integration hooks are declared but not yet wired.
- Plugin-Permissions: db:own also covers db.migrate; a host must appear as
  both an http:outbound:<host> permission and an egress[] entry or it's
  silently blocked; bare vs per-host outbound.
- Plugin-Publishing: the new one-command flow (validate → pack → release →
  entry), size is a required entry field, signing reconciled with the schema,
  no reserved namespaces, and the --merge update path.

* chore(plugin-sdk): make it npm-publishable so `npx` resolves for authors

The docs told authors to run `npx create-trek-plugin` / `npx trek-plugin`,
but nothing published under those names, so npx couldn't resolve them.

- Ship one package, `trek-plugin-sdk`, with a bin that matches the package
  name (`trek-plugin-sdk`) so `npx trek-plugin-sdk <command>` resolves with
  zero install. The dispatcher gained a `create` subcommand, so every step
  (create/validate/pack/entry/release) runs through that one entry point.
  The short `trek-plugin` / `create-trek-plugin` bins still work once
  installed.
- Package hardening for publish: repository+directory (monorepo subdir),
  homepage/bugs/author/engines, publishConfig public, a prepublishOnly that
  builds + tests, and a LICENSE file.
- A publish workflow: pushing a `plugin-sdk-v*` tag builds and publishes with
  the NPM_TOKEN repo secret.
- Docs (SDK README + the four wiki pages) now use `npx trek-plugin-sdk <cmd>`,
  the invocation that actually resolves.

* feat(plugin-sdk): dev server, preflight, auto-PR submit, signing, wizard

Round out the author experience so the loop is create -> dev -> release/submit
without hand-work or a round-trip through registry review.

- `dev`: run a plugin locally with a real request loop and hot reload — no full
  TREK. Injects a ctx that enforces the manifest's granted permissions (an
  ungranted call throws, so you catch a missing grant), backs db:own with a real
  SQLite file (node:sqlite), serves routes under /api and page/widget UI at /ui,
  and reloads on save. Dependency-free (node:http + built-ins).
- `preflight`: run the registry CI checks locally over the network (tag->commit,
  manifest parity, artifact sha256/size, native scan, README quality gate) so a
  green run predicts a green CI.
- `submit`: fork TREK-Plugins, branch off current main, write/merge the entry,
  push, and open the PR — the last manual publishing step, automated.
- `keygen`/`sign` + `--sign` on entry/release/submit: dependency-free Ed25519
  author signatures over the artifact bytes, verified 1:1 against the server's
  TOFU check. Fills authorPublicKey + signature and guards against a key change.
- `create` gains an interactive wizard (id/type/author/permissions) and flags.
- README + Development/Publishing/Permissions wikis document the new flow.

24 tests pass (sign round-trips through a server-shaped verifier; zip reader;
scaffold options; entry signing + key-change guard).

* feat(plugin-sdk): one-command `publish` (pack → release → preflight → PR)

Collapses the release into a single command: pack the artifact, tag + create the
GitHub release, run the registry CI checks locally (preflight), and open the
registry PR — stopping before it submits if preflight would fail, so a broken
entry never becomes a doomed PR. `--sign` signs it; `--no-preflight` skips the
gate. The individual pack/release/preflight/submit commands still exist.

README + the Development/Publishing/Permissions wikis lead with `publish` now.

* fix(plugins): security hardening from the PR #1415 audit

Remediates the findings from the adversarial audit (threat model: malicious
plugin author + malicious artifact). Highlights:

Critical
- proxy: force nosniff + Content-Disposition: attachment on every proxied reply
  and drop location/content-disposition + non-2xx from the passthrough, so a
  plugin can't serve an HTML document at TREK's origin (sandbox-escape → account
  takeover) or an open redirect.

High
- db:own runs synchronously in the host: cap the plugin DB (max_page_count) and
  row-cap query() via iterate() so a recursive CTE / huge blob can't stall the
  event loop, OOM, or exhaust the shared volume.
- supervisor: measure child RSS host-side (/proc/<pid>/statm) instead of trusting
  the spoofable heartbeat; add an activation timeout so a stuck onLoad can't hang
  activate() or peg a core unreaped.
- safe-extract: enforce entry-count + cumulative-size limits INSIDE readZip
  before inflating (decompression-bomb OOM).
- re-consent: activate() never widens granted permissions without explicit
  consent (409 CONSENT_REQUIRED); the row toggle + "Update All" route through the
  consent dialog, which now queues instead of overwriting.

Medium/low
- egress: gate dgram hostnames through the IP-vetting resolver; block the
  low-level socket escape (process.binding) + lock the wrapped prototypes;
  canonicalize IPv6 in isBlockedIp (hex-mapped/compressed metadata); reject
  degenerate `*.` / whole-TLD / spaced outbound hosts in the manifest + CSP.
- ws:broadcast is membership-gated to the acting user's trips / own connections;
  users.getById is scoped to users the acting user can see (no enumeration).
- safe-fetch streams + aborts at the byte cap (chunked codeload OOM); isPrivateIp
  reuses the canonicalizing check.
- native-scan throws instead of silently passing past its entry cap.
- SDK: dev serves binary assets as raw buffers + handles EADDRINUSE; manifest
  validator gains the reserved-id + outbound-host checks; wikis corrected.

Tests updated for the new membership-gated behaviour + regression tests added
(IPv6 canonicalization, wildcard hardening, outbound-host validation, ws/user
scoping). 234 plugin tests + 24 SDK tests green.

* fix(plugins): close the 4 PARTIAL findings + regressions from the fix-verify pass

A second adversarial pass over the first remediation found four findings only
partially closed and five issues the fixes themselves introduced. This closes
them:

Partial → closed
- re-consent gate keyed on `granted.length > 0`, so a plugin first activated with
  ZERO permissions (granted '[]') was treated as never-consented and a later
  widening was granted silently. Now discovery marks a never-consented plugin with
  granted_permissions '' and activate() gates on "ever consented" (any non-empty
  string, including '[]').
- db:own DoS: block WITH RECURSIVE outright (the one construct that spins the
  synchronous host unboundedly regardless of the size/row caps, via query OR exec).
- dgram: also wrap `new dgram.Socket(...)` (bypassed createSocket) to inject the
  IP-vetting lookup, and lock createSocket/Socket.
- frame self-navigation: documented as a bounded best-effort mitigation (inherent
  to sandboxed iframes; exposure is the plugin's own routes + already-held context,
  never the httpOnly cookie).

Regressions introduced by the first pass → fixed
- proxy: only real redirects (301/302/303/307/308) are gated, to a RELATIVE in-app
  Location (supports OAuth-callback bounce, blocks open redirect); 300/304 pass
  through; attachment only on non-redirects.
- supervisor: measure RSS via /proc/<pid>/status VmRSS (page-size independent);
  activation-timeout awaits kill() before disposing the db handle.
- manifest HOST_RE: allow single-label hosts (self-hoster sibling services) while
  keeping wildcards multi-label; mirrored in the SDK + frame CSP filter.

Regression tests added (re-consent incl. the '[]' case, WITH RECURSIVE + row cap,
single-label host). 236 plugin tests + 24 SDK tests green.

* docs: refresh README screenshots (8) + swap the second trip shot for Collections

Replaces all eight README gallery screenshots with current-UI captures and swaps
docs/screenshots/trip-iceland.png for collections.png (saved place lists).

* fix(plugin-sdk): make require('trek-plugin-sdk') actually resolve everywhere

A freshly scaffolded plugin could not load anywhere: the npm package is
ESM-only (no require condition in its exports map), so the scaffold's
require('trek-plugin-sdk') threw ERR_PACKAGE_PATH_NOT_EXPORTED under
`trek-plugin dev` - and the runtime injection the wiki promised for the
plugin child never existed, so a packed plugin (node_modules stripped)
crashed with MODULE_NOT_FOUND after a real install.

- plugin child: inject a frozen {definePlugin, PLUGIN_API_VERSION} shim
  for require('trek-plugin-sdk'); subpaths fail with a pointed error
- trek-plugin dev: inject the exact same shim, so a fresh scaffold runs
  with zero npm install and dev parity with production holds
- npm package: ship a real CommonJS build (dist/cjs + require export
  conditions) so the installed package also requires cleanly on Node 18+
- create: scaffold a package.json (type commonjs, SDK as devDependency,
  npx scripts); print resolvable `npx trek-plugin-sdk ...` hints
- wiki: package.json in the scaffold tree + publishing checklist, and
  document the zero-install dev flow

* fix(plugins): tolerate a UTF-8 BOM in trek-plugin.json

Windows editors love to prepend a BOM, and a bare JSON.parse then dies
with an "Unexpected token" pointing at an invisible character - in the
SDK CLIs (dev/validate/entry/submit) and, worse, server-side: a BOM in
an author repo travels through pack into the artifact and fails
discovery and registry install. Strip it at every manifest/JSON read
(readJsonFile in the SDK, parseJsonText in the installer).

* fix(plugin-sdk): dev db binds an args array like the real host, and a failed onLoad stops the routes

* ci(plugin-sdk): publish on Node 22; skip the dev-db bind test without node:sqlite

* docs(wiki): document AI booking import, guest members and packing sharing

Fill the gaps left after the 3.2.0 feature work:
- add an AI Booking Import page for the AI Parsing addon (providers,
  admin/per-user config, model pull, the review-before-save flow) and link
  it from Reservations & Bookings and the sidebar
- document guest members on Trip Members and Sharing (owner-only, what they
  can be assigned to, and the sign-in/notification/visibility limits)
- document the three packing sharing tiers and co-bringing on Packing Lists
- add TRANSIT_API_URL and the plugin variables to Environment Variables,
  and correct the language list to 22 (add Swedish and Vietnamese)
- list the airtrail and llm_parsing addons in the Addons overview

* feat(plugins): enable the plugin system by default

The runtime and the Admin -> Plugins panel are now available out of the box;
TREK_PLUGINS_ENABLED becomes an opt-out (set it to false to switch the whole
system off). Installed plugins are still registered inactive and have to be
activated one by one, so no third-party code runs until an admin turns a
specific plugin on.

Update the kill-switch default test and the plugin/env-var wiki pages to match.

* fix(costs): KGS is selectable as default/expense currency (#1400)

* fix(map): render date-line-crossing routes as one continuous arc (#1411)

The great-circle sampler normalizes longitudes to [-180,180], so a
transpacific leg jumped +-360 between neighbours and got split into two
polylines pinned to opposite map edges. Unwrap the longitudes instead
(shared flightGeodesy module for both renderers): Leaflet additionally
draws a +-360-shifted copy so both halves show in the standard view, GL
maps repeat world copies themselves.

* fix(map): clear the hover card on marker click and camera moves (#1404)

Clicking an off-center place recenters the map under a stationary
cursor, so mouseout/mouseleave never fires and the hover card sticks.
Clear it on marker click and on movestart, and suppress re-shows while
the camera is animating (marker rebuilds re-fire mouseenter mid-pan).

feat(map): long-press + plain right-click add-place on GL maps (#1398)

The GL providers only bound middle-click, so mobile had no way to add a
place at a position (and Macs have no middle button). Add a 600ms touch
long-press with move tolerance and the map contextmenu event - both GL
libs suppress it while the right-button rotate/pitch drag is active, so
the gesture keeps winning.

* fix(mcp): keep SSE streams alive and stop invalidating sessions on unrelated saves (#1414)

Three separate causes for the reconnect-per-tool-call pain:
- no keep-alive on the standalone GET stream, so reverse proxies with
  idle timeouts (nginx default 60s) killed it between calls - send an
  SSE comment ping every 25s (MCP_SSE_KEEPALIVE, 0 = off) and count an
  open stream as session activity
- the session TTL was hard-coded - MCP_SESSION_TTL (seconds, clamped to
  24h) now works as the issue expected
- every addon save invalidated ALL sessions: config-only saves, photo
  provider toggles and addons with no MCP surface included. Only a real
  enabled-flip of an MCP-relevant addon (or an actual collab-feature
  change) tears sessions down now.

* feat(api): OpenAPI/Swagger docs at /api/docs behind TREK_API_DOCS_ENABLED (#1412)

Swagger UI + raw spec (/api/docs-json, -yaml) over all controllers, with
a bearer button that works with a plain session JWT. Off by default -
the spec enumerates the whole surface incl. admin routes, so exposing
it is an explicit self-hoster decision (same kill-switch pattern as
TREK_PLUGINS_ENABLED).

Request bodies come from the Zod schemas the routes already validate
with: an enricher walks every controller, finds whole-body
ZodValidationPipe params and lifts their schema into the document via
zod v4's native z.toJSONSchema - nothing is annotated twice, and any
route that gains a Zod pipe is documented automatically.

* fix(map,mcp): review follow-ups for the issue-fix batch

- mapbox-gl (unlike maplibre) still emits the map contextmenu after a
  right-button rotate/pitch drag on Windows - guard it with the pressed
  position so ending a rotate can't open the Add-Place form (#1398)
- a long-press whose fire was deduped (or that never yields a click) no
  longer leaves suppressNextClick armed to swallow a later real tap
- MCP_SSE_KEEPALIVE=0 keeps the open-stream-counts-as-activity
  guarantee: the touch interval survives, only the pings stop (#1414)
- swagger-ui-dist ships @scarf/scarf install-time analytics - disabled
  via scarfSettings in the root package.json, TREK sends no telemetry
- Budget wiki currency list: 47 incl. KGS (#1400)

* feat(map): real road routes for car/bus/taxi/bicycle bookings instead of straight lines

Road-based transport bookings drew an as-the-crow-flies line; only
transit journeys (Transitous) showed the real path. A shared
useTransportRoutes hook now fetches the OSRM road geometry (driving for
car/bus/taxi, cycling for bicycle) — reusing the day-route router and
its cache — and both renderers draw it in place of the straight arc,
falling back to the straight line until it loads or if routing fails.
Trains/other keep their straight line (not road-routable); a 2000 km
sanity cap avoids hammering the public router on cross-continent quirks.

* feat(transport): multi-leg train bookings (#1150)

Long train trips are usually several trains under one booking. Trains
now get the same multi-leg editor flights have: an ordered chain of
stations (station search instead of the airport picker) with a per-leg
train number + platform, saved as from/stop/to endpoints + metadata.legs
— mirroring the flight leg contract, so the map draws the whole chain
and the day plan splits it into one row per leg (drag/reorder/position
persistence come for free from the shared __leg machinery). A single-leg
train saves exactly as before (flat metadata, no legs), and the flat
train-fields block is gone in favour of the per-leg inputs. Day sidebar,
shared trip view and the PDF render each train leg like a flight leg.

* feat(collections): per-collection custom labels

Each list can now define its own labels (e.g. Berlin, Hamburg, Ostsee in a
"Germany 2026" list) and organise its places by them:

- manage labels (create / rename / recolor / delete) from a label manager
- assign labels to a place from its detail sheet, or to many places at once
  from the selection toolbar
- filter the place list AND the map by label (multi-select, any-match)

Labels are scoped to a collection and shared by all its members. Managing and
assigning labels needs edit rights; filtering is available to everyone. Moving
a place to another list drops its labels, since they belong to the source list.

* test(collections): pass the required labels prop in CollectionPlaceDetail test

The per-collection labels feature (a5522e99) made `labels` a required prop
and renders `labels.filter(...)`, but the test's props cast to
Omit<DetailProps,'t'> hid the missing prop, so `labels` was undefined at
runtime and crashed the whole suite (Cannot read properties of undefined
reading 'filter'). Pass labels: [] like categories.

* docs(wiki): document collection labels, multi-leg trains and road-route overlays

- Collections: add a Custom labels section (manage / assign / filter), note the
  label filter + bulk assign, and the view-vs-edit permission split
- Transport: rewrite the train fields as the multi-leg route editor, correct the
  transport type list (nine types) and the map/day-plan behaviour
- Map Features: car/bus/taxi/bicycle overlays follow real roads; multi-leg trains
  draw their full station chain; date-line routes render as one continuous arc

---------

Co-authored-by: jubnl <jgunther021@gmail.com>
Co-authored-by: jufy111 <jeffturner93@gmail.com>
Co-authored-by: Azalea <noreply@aza.moe>
Co-authored-by: Zorth Thorch <jasper_goens@hotmail.com>
Co-authored-by: leeduc <lee.duc55@gmail.com>
Co-authored-by: yael-tramier <tramier.yael@gmail.com>
Co-authored-by: michael-bohr <mjbohr@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Gio Cettuzzi <gio.cettuzzi@gmail.com>
Co-authored-by: mauriceboe <mauriceboe@users.noreply.github.com>
Co-authored-by: jubnl <66769052+jubnl@users.noreply.github.com>
2026-07-05 01:13:04 +02:00
github-actions[bot] 75e3bb3985 chore: bump version to 3.1.4 [skip ci] 2026-07-01 16:57:52 +00:00
jubnl 819aa793ae v3.1.4 (#1371)
* fix(backups): prevent recursion in path that is backed up

* fix(share): show user currency instead of the default euro in the share page

* fix(files): show descriptive error for unsupported upload type

Unsupported file uploads showed a generic 'Upload failed' toast even
though the server already returns a descriptive 400. The client catch
blocks discarded the error and always showed t('files.uploadError').

The server now emits the i18n key 'files.uploadErrorType' as its error
message; a new translateApiError() helper resolves a server message that
is a known translation key via t() and falls back to the generic key
otherwise. Wired into the three trip-file upload catch sites.

Closes #1363
2026-07-01 18:57:16 +02:00
3857 changed files with 541868 additions and 60485 deletions
+2 -2
View File
@@ -15,6 +15,8 @@ uploads
chart/
docs/
docker-compose.yml
docker-compose.minio-test.yml
minio-licence.key
unraid-template.xml
*.sqlite
*.sqlite-shm
@@ -30,9 +32,7 @@ Thumbs.db
sonar-project.properties
server/tests/
server/vitest.config.ts
server/reset-admin.js
**/*.test.ts
**/*.spec.ts
wiki/
scripts/
charts/
+1
View File
@@ -15,6 +15,7 @@
*.sh text eol=lf
# Binary files — no line ending conversion
*.png binary
*.webp binary
*.jpg binary
*.jpeg binary
*.gif binary
+2 -2
View File
@@ -8,11 +8,11 @@ body:
attributes:
label: Pre-flight checklist
options:
- label: I have searched [existing issues](https://github.com/mauriceboe/TREK/issues) and this bug has not been reported yet
- label: I have searched [existing issues](https://github.com/liketrek/TREK/issues) and this bug has not been reported yet
required: true
- label: I am running the latest available version of TREK
required: true
- label: I have read the [Troubleshooting guide](https://github.com/mauriceboe/TREK/wiki/Troubleshooting) and my issue is not covered there
- label: I have read the [Troubleshooting guide](https://github.com/liketrek/TREK/wiki/Troubleshooting) and my issue is not covered there
required: true
- type: input
+3 -3
View File
@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Documentation
url: https://github.com/mauriceboe/TREK/wiki
url: https://github.com/liketrek/TREK/wiki
about: Check the docs before opening an issue
- name: Feature Request
url: https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests
url: https://github.com/liketrek/TREK/discussions/new?category=feature-requests
about: Suggest a new feature or improvement in Discussions
- name: Questions & Help
url: https://github.com/mauriceboe/TREK/discussions
url: https://github.com/liketrek/TREK/discussions
about: For questions and general help, use Discussions instead
+2 -2
View File
@@ -13,8 +13,8 @@
- [ ] Documentation update
## Checklist
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
- [ ] I have read the [Contributing Guidelines](https://github.com/liketrek/TREK/wiki/Contributing)
- [ ] My branch is [up to date with `dev`](https://github.com/liketrek/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
- [ ] This PR targets the `dev` branch, not `main` *(wiki-only PRs are exempt)*
- [ ] I have tested my changes locally
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
@@ -9,6 +9,7 @@ permissions:
jobs:
close-stale:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
steps:
- name: Close stale invalid-title issues
@@ -10,6 +10,7 @@ permissions:
jobs:
close-stale:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
steps:
- name: Close stale wrong-base-branch PRs
+2 -1
View File
@@ -9,6 +9,7 @@ permissions:
jobs:
check-title:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
steps:
- name: Flag or redirect issue
@@ -76,7 +77,7 @@ jobs:
body: [
'## Wrong place for feature requests',
'',
'Feature requests should be submitted in [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests), not as issues.',
'Feature requests should be submitted in [Discussions](https://github.com/liketrek/TREK/discussions/new?category=feature-requests), not as issues.',
'',
'This issue has been closed. Feel free to re-submit your idea in the right place!',
].join('\n'),
+19 -2
View File
@@ -18,6 +18,7 @@ concurrency:
jobs:
version-bump:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.VERSION }}
@@ -131,7 +132,8 @@ jobs:
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
# Deliberately NOT merged: the per-platform folder names are what lets
# the manifest below be assembled in a fixed order.
- uses: docker/setup-buildx-action@v3
@@ -143,9 +145,24 @@ jobs:
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
set -euo pipefail
VERSION="${{ needs.version-bump.outputs.version }}"
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)-pre"
# amd64 first, always. This used to be a glob over hash-named files, so
# the order changed from one release to the next: 3.4.1 listed amd64
# first and 4.0.0 listed arm64 first. Clients that read the first entry
# of the index rather than matching their own platform then compare the
# wrong digest and report "no update" — which is what Unraid users hit
# on :latest after 4.0.0 shipped. The image was fine; the order was not.
digests=()
for platform in linux-amd64 linux-arm64; do
dir="digests-$platform"
[ -d "$dir" ] || { echo "::error::missing digest artifact for $platform"; exit 1; }
for f in "$dir"/*; do
[ -e "$f" ] || { echo "::error::no digest file in $dir"; exit 1; }
digests+=("mauriceboe/trek@sha256:$(basename "$f")")
done
done
docker buildx imagetools create \
-t "mauriceboe/trek:latest-pre" \
-t "mauriceboe/trek:$MAJOR_TAG" \
+28 -14
View File
@@ -1,16 +1,6 @@
name: Build & Push Docker Image
on:
push:
branches: [main]
paths-ignore:
- 'docs/**'
- '**/*.md'
- 'wiki/**'
- '.github/workflows/**'
- '.github/ISSUE_TEMPLATE/**'
- '.github/FUNDING.yml'
- '.github/PULL_REQUEST_TEMPLATE.md'
workflow_dispatch:
inputs:
bump:
@@ -32,15 +22,22 @@ concurrency:
jobs:
version-bump:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.VERSION }}
steps:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
token: ${{ secrets.GITHUB_TOKEN }}
token: ${{ steps.app-token.outputs.token }}
- name: Determine bump type and update version
id: bump
@@ -112,7 +109,7 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag "v$NEW_VERSION"
git tag -a "v$NEW_VERSION" -m "v$NEW_VERSION"
git push origin main --follow-tags
build:
@@ -179,7 +176,8 @@ jobs:
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
# Deliberately NOT merged: the per-platform folder names are what lets
# the manifest below be assembled in a fixed order.
- uses: docker/setup-buildx-action@v3
@@ -191,9 +189,24 @@ jobs:
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
set -euo pipefail
VERSION="${{ needs.version-bump.outputs.version }}"
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
# amd64 first, always. This used to be a glob over hash-named files, so
# the order changed from one release to the next: 3.4.1 listed amd64
# first and 4.0.0 listed arm64 first. Clients that read the first entry
# of the index rather than matching their own platform then compare the
# wrong digest and report "no update" — which is what Unraid users hit
# on :latest after 4.0.0 shipped. The image was fine; the order was not.
digests=()
for platform in linux-amd64 linux-arm64; do
dir="digests-$platform"
[ -d "$dir" ] || { echo "::error::missing digest artifact for $platform"; exit 1; }
for f in "$dir"/*; do
[ -e "$f" ] || { echo "::error::no digest file in $dir"; exit 1; }
digests+=("mauriceboe/trek@sha256:$(basename "$f")")
done
done
docker buildx imagetools create \
-t "mauriceboe/trek:latest" \
-t "mauriceboe/trek:$MAJOR_TAG" \
@@ -217,3 +230,4 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
charts_dir: charts
charts_url: https://chart.liketrek.com
@@ -6,6 +6,7 @@ on:
jobs:
check-target:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
permissions:
pull-requests: write
@@ -67,6 +68,38 @@ jobs:
return;
}
// Dependabot cannot be pointed at dev. `target-branch` in dependabot.yml
// applies to version updates only; security updates always open against
// the default branch, and security updates are all this repository gets
// (there is no dependabot.yml). Labelling them meant close-stale-wrong-branch
// shut them 24 hours later, which is how 13 of them died. Retarget instead:
// a bot cannot click "Edit" the way the comment below asks a human to.
if (context.payload.pull_request.user.login === 'dependabot[bot]') {
try {
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
base: 'dev',
});
if (labels.includes('wrong-base-branch')) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: 'wrong-base-branch',
});
}
console.log('Retargeted the Dependabot PR onto dev.');
return;
} catch (err) {
// Retargeting can fail when the branch was cut from main and dev has
// moved on. Fall through to the normal path so a human sees it rather
// than the check passing on a PR that is still aimed at main.
console.log(`Could not retarget the Dependabot PR: ${err.message}`);
}
}
// Base is main — check if this user is a maintainer
let permission = 'none';
try {
+5
View File
@@ -2,6 +2,11 @@ name: Lint & Prettier
on:
pull_request:
branches: [main, dev]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
+33
View File
@@ -0,0 +1,33 @@
name: Publish plugin-sdk to npm
# Publishes trek-plugin-sdk when a tag like `plugin-sdk-v1.2.0` is pushed.
# One-time setup: add an npm automation token as the repo secret NPM_TOKEN.
# The package's prepublishOnly hook builds + tests before publishing.
on:
push:
tags:
- 'plugin-sdk-v*'
permissions:
contents: read
jobs:
publish:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
defaults:
run:
working-directory: plugin-sdk
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
# 22 matches the TREK server runtime and has node:sqlite, which the
# dev-server tests exercise.
node-version: 22
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+7
View File
@@ -9,8 +9,15 @@ on:
permissions:
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
scout:
# Docker Hub secrets are not exposed to pull requests from forks, so the
# Scout login can never succeed there.
if: github.repository == 'liketrek/TREK' && github.event.pull_request.head.repo.fork != true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+400 -52
View File
@@ -1,18 +1,115 @@
name: Tests
name: Tests & SonarQube
permissions:
contents: read
# The repo's test/quality CI and SonarQube Cloud analysis in one workflow, so
# each test suite runs once per event instead of once for the old test.yml and
# once more for Sonar's coverage. Pushes to dev and main produce branch
# analysis; pull requests get PR analysis with decoration on the PR itself
# (the scanner picks the context up from the Actions environment).
#
# Job layout — three tiers:
# - Preflight: plugin-facts, i18n-parity and the quality jobs
# (server-quality, client-quality, plugin-sdk-quality) hold the cheap
# generated-code/typecheck/lint/smoke gates. Every test job `needs:` all
# of them, so a gate failure stops the run before the expensive coverage
# fan-out spends ~15 runner-minutes on a result the gates already sank.
# - Tests: the *-coverage jobs run the suites and upload lcov; s3-contract
# runs the MinIO contract suite. Coverage jobs run tests only — with the
# gates cleared in preflight, they can only fail because tests failed.
# - Scan `needs:` exactly the four coverage jobs. A coverage job failing
# must stop the scan — a missing lcov file would make every line of that
# package report as uncovered, and the dashboard would show a collapse
# that did not happen. (The preflight gates stop it transitively.)
# - Fork PRs don't receive secrets, so the two jobs that need them skip
# themselves: s3-contract (MINIO_LICENSE_KEY) and scan (SONAR_TOKEN).
# Everything else runs on fork PRs so outside contributors still get the
# full test signal.
#
# Automatic Analysis must stay disabled on sonarcloud.io — it is mutually
# exclusive with CI-based analysis, and it cannot import the lcov coverage
# this workflow produces.
# A push or PR that touches none of these paths can't change the tests or the
# analysis, so don't spend the coverage fan-out on it. For a docs-only push
# that means no run and no fresh branch analysis — harmless, since the last
# analysis is still accurate for unchanged code. GitHub Actions doesn't
# support YAML anchors, so the two lists are literal copies: keep them
# identical when editing either one.
on:
push:
branches: [main, dev]
paths:
- 'server/**'
- 'client/**'
- 'shared/**'
- 'plugin-sdk/**'
# npm workspaces hoist every dependency into the single root lockfile,
# so a dependency bump touches none of the four directories above — and
# a native binding like better-sqlite3 is exactly what needs the suite.
- 'package.json'
- 'package-lock.json'
- 'sonar-project.properties'
- '.github/workflows/test.yml'
# Docs inside the workspaces (CLAUDE.md, READMEs, PATTERN.md) affect
# neither the tests nor the scan — no test reads checked-in markdown
# and SonarCloud has no markdown analyzer. Negations must stay last.
- '!**/*.md'
pull_request:
branches: [main, dev]
paths:
- 'server/**'
- 'client/**'
- 'shared/**'
- 'plugin-sdk/**'
# npm workspaces hoist every dependency into the single root lockfile,
# so a dependency bump touches none of the four directories above — and
# a native binding like better-sqlite3 is exactly what needs the suite.
- 'package.json'
- 'package-lock.json'
- 'sonar-project.properties'
- '.github/workflows/test.yml'
# Docs inside the workspaces (CLAUDE.md, READMEs, PATTERN.md) affect
# neither the tests nor the scan — no test reads checked-in markdown
# and SonarCloud has no markdown analyzer. Negations must stay last.
- '!**/*.md'
workflow_dispatch:
# A newer push to the same branch/PR supersedes the running one — cancel it
# rather than spending the coverage fan-out on a stale commit. Safe here
# because the Sonar scan job runs last and only the latest commit matters.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
plugin-facts:
name: Plugin Permission Facts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
# 22 matches the TREK server runtime (same choice as the plugin-sdk
# jobs and publish-plugin-sdk.yml).
node-version: 22
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace server
# The plugin permission facts live once, in
# server/src/nest/plugins/protocol/envelope.ts. Every other copy — the
# SDK's generated/host-facts.ts and shared's plugin-permissions.ts — is
# generated from it and checked in, because publish-plugin-sdk builds
# from a standalone checkout with no server present. This fails the run
# if a copy drifts, if a new permission has no admin.plugins.perm.*
# translation, or if the CLI has no hint for it.
- name: Check the generated plugin facts are in sync
run: cd server && npm run check:plugin-facts
i18n-parity:
name: i18n Key Parity
runs-on: ubuntu-latest
@@ -27,32 +124,9 @@ jobs:
- name: Check i18n key parity
run: node shared/scripts/i18n-parity.mjs --strict
shared-contracts:
name: Shared Contracts (Zod)
server-quality:
name: Server Types & Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared
- name: Typecheck
run: cd shared && npm run typecheck
- name: Run tests
run: cd shared && npm test
server-tests:
name: Server Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
@@ -65,40 +139,40 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Ensure @swc/core's Linux binary for unplugin-swc
# The lockfile was generated on Windows and omits @swc/core's Linux
# optional native binary, so npm ci/install skips it on the runner.
# Install the matching version explicitly so the server's SWC transform
# (server/vitest.config.ts) can load.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Build shared
run: npm run build --workspace=shared
- name: Typecheck shared
# Rides along here rather than in shared-coverage: this job already
# pays the root install and shared build, and a coverage job may only
# fail because tests failed (it gates the scan).
run: cd shared && npm run typecheck
- name: Build server (tsc -> dist)
run: cd server && npm run build
- name: Smoke production require chain
# Vitest aliases the MCP SDK deep imports, so only this exercises what
# production runs: the built dist resolving the MCP SDK's subpath
# exports through the tsconfig-paths/register runtime hook.
run: cd server && node --require tsconfig-paths/register -e "require('./dist/nest-mcp')"
- name: Typecheck
run: cd server && npm run typecheck
- name: Typecheck tests
# tests/ is outside the build tsconfig, so a service that grows a
# constructor parameter does not break its hand-wired call sites at
# compile time: the argument arrives as undefined and throws at
# runtime, if anything reads it at all. This is the step that
# catches it.
run: cd server && npm run typecheck:tests
- name: Lint
run: cd server && npm run lint:check
- name: Run tests
run: cd server && npm run test:coverage
- name: Upload coverage
if: success()
uses: actions/upload-artifact@v6
with:
name: backend-coverage
path: server/coverage/
retention-days: 7
client-tests:
name: Client Tests
client-quality:
name: Client Types & Lint
runs-on: ubuntu-latest
steps:
@@ -125,13 +199,287 @@ jobs:
- name: Page pattern check
run: cd client && npm run lint:pages
- name: Run tests
plugin-sdk-quality:
name: Plugin SDK Types
runs-on: ubuntu-latest
defaults:
run:
working-directory: plugin-sdk
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
# plugin-sdk is standalone (own lockfile, not a root workspace), so it
# installs from its own directory.
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
s3-contract:
name: S3 Driver Contract (MinIO)
runs-on: ubuntu-latest
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
# Fork PRs don't get repo secrets, so MINIO_LICENSE_KEY would be empty and
# the AIStor container would fail to boot. Pushes and manual dispatches do
# have secrets, so only fork PRs are filtered out. (The implicit success()
# on this expression also keeps the job skipped when a preflight fails.)
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Ensure @swc/core's Linux binary for unplugin-swc
# The lockfile was generated on Windows and omits @swc/core's Linux
# optional native binary, so npm ci/install skips it on the runner.
# Install the matching version explicitly so the server's SWC transform
# (server/vitest.config.ts) can load.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Build shared
run: npm run build --workspace=shared
- name: Write MinIO license
run: printf '%s' "${{ secrets.MINIO_LICENSE_KEY }}" > minio-licence.key
- name: Start MinIO
# `services:` can't pass the `server /data --license ...` command to
# the AIStor image, so it starts via docker run. 127.0.0.1 endpoint is
# load-bearing: aws-lite picks path-style only for IP/localhost hosts,
# which is all stock MinIO/AIStor speaks.
run: |
docker run -d --name minio -p 127.0.0.1:9000:9000 \
-v "$PWD/minio-licence.key:/minio.license:ro" \
-e MINIO_ROOT_USER=trekci -e MINIO_ROOT_PASSWORD=trekci-secret \
quay.io/minio/aistor/minio:RELEASE.2026-08-07T18-34-35Z server /data --license /minio.license
- name: Wait for MinIO
# curl was removed from the AIStor image — poll from the runner instead.
# 60 x 2s gives a wider margin on cold runners than the 30 x 1s the
# pre-6fbff835 job used.
run: |
for i in $(seq 1 60); do
curl -sf http://127.0.0.1:9000/minio/health/live && exit 0
sleep 2
done
echo "MinIO never became healthy" >&2
docker logs minio >&2
exit 1
- name: Run the S3 driver contract suite
# No --coverage: the src/nest/storage/** 94/84/97/94 pin must keep
# holding from hermetic tests alone. The suite's beforeAll creates
# the trek-test bucket idempotently — no separate bucket-init step.
env:
TREK_TEST_S3_ENDPOINT: http://127.0.0.1:9000
TREK_TEST_S3_BUCKET: trek-test
TREK_TEST_S3_ACCESS_KEY_ID: trekci
TREK_TEST_S3_SECRET_ACCESS_KEY: trekci-secret
run: cd server && npx vitest run tests/unit/nest/storage/s3.driver.contract.test.ts
server-coverage:
name: Server Coverage
runs-on: ubuntu-latest
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Ensure @swc/core's Linux binary for unplugin-swc
# The lockfile was generated on Windows and omits @swc/core's Linux
# optional native binary, so npm ci/install skips it on the runner.
# Install the matching version explicitly so the server's SWC transform
# (server/vitest.config.ts) can load.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Build shared
run: npm run build --workspace=shared
- name: Run tests with coverage
run: cd server && npm run test:coverage
- name: Upload coverage
uses: actions/upload-artifact@v6
with:
name: sonar-server-coverage
path: server/coverage/
retention-days: 1
client-coverage:
name: Client Coverage
runs-on: ubuntu-latest
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
# Only the shared and client workspaces — a full root npm ci is not
# needed, and the scoped install keeps @testing-library/dom (a peer of
# @testing-library/react) in the tree without the re-resolution dance
# the folded coverage job used to need.
- name: Install dependencies
run: npm ci --workspace shared && npm ci --workspace client
- name: Build shared
run: npm run build --workspace=shared
- name: Run tests with coverage
run: cd client && npm run test:coverage
- name: Upload coverage
if: success()
uses: actions/upload-artifact@v6
with:
name: frontend-coverage
name: sonar-client-coverage
path: client/coverage/
retention-days: 7
retention-days: 1
shared-coverage:
name: Shared Coverage
runs-on: ubuntu-latest
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared
- name: Run tests with coverage
run: cd shared && npm run test:coverage
- name: Upload coverage
uses: actions/upload-artifact@v6
with:
name: sonar-shared-coverage
path: shared/coverage/
retention-days: 1
plugin-sdk-coverage:
name: Plugin SDK Coverage
runs-on: ubuntu-latest
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
defaults:
run:
working-directory: plugin-sdk
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
# 22 matches the TREK server runtime and has node:sqlite, which the
# dev-server tests exercise (same choice as publish-plugin-sdk.yml).
node-version: 22
# plugin-sdk is standalone (own lockfile, not a root workspace), so it
# installs from its own directory.
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npm run test:coverage
- name: Upload coverage
uses: actions/upload-artifact@v6
with:
name: sonar-plugin-sdk-coverage
path: plugin-sdk/coverage/
retention-days: 1
scan:
name: Scan
runs-on: ubuntu-latest
# Fork PRs don't receive SONAR_TOKEN — skip the scan rather than fail it.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
# Coverage jobs failing must stop the scan: a missing lcov file would make
# every line of that package report as uncovered, and the dashboard would
# show a collapse that did not happen. The preflight gates already stop it
# transitively through these four, so they aren't repeated here.
needs: [server-coverage, client-coverage, shared-coverage, plugin-sdk-coverage]
steps:
- uses: actions/checkout@v6
with:
# Sonar reads git blame to date each issue and to decide what counts
# as new code. A shallow clone leaves it blind to both.
fetch-depth: 0
- name: Download server coverage
uses: actions/download-artifact@v6
with:
name: sonar-server-coverage
path: server/coverage/
- name: Download client coverage
uses: actions/download-artifact@v6
with:
name: sonar-client-coverage
path: client/coverage/
- name: Download shared coverage
uses: actions/download-artifact@v6
with:
name: sonar-shared-coverage
path: shared/coverage/
- name: Download plugin-sdk coverage
uses: actions/download-artifact@v6
with:
name: sonar-plugin-sdk-coverage
path: plugin-sdk/coverage/
- name: Scan
uses: SonarSource/sonarqube-scan-action@v8
env:
# SonarQube Cloud is the scan action's default host, so no
# SONAR_HOST_URL — the token is a sonarcloud.io token for the
# liketrek org.
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
# New Code is configured as "previous version", so this string is what
# draws that line — changing it re-bases every "new code" metric.
# Pinned rather than read from package.json on purpose: package.json is
# still on 3.4.1 while the project has been analysed as 4.0.0 all along,
# and taking the lower number would silently move the baseline. Bump it
# here as part of the release, together with the version bump.
#
# Everything else — sources, tests, exclusions, the documented
# false-positive list — lives in sonar-project.properties so it stays
# reviewable in a PR rather than buried in workflow YAML.
args: -Dsonar.projectVersion=4.0.0
+1
View File
@@ -17,6 +17,7 @@ concurrency:
jobs:
deploy:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+5 -1
View File
@@ -51,6 +51,7 @@ yarn-error.log*
# Coverage
coverage
coverage-*/
*.lcov
.nyc_output
@@ -64,5 +65,8 @@ coverage
.scannerwork
test-data
# Live-MinIO/AIStor manual test license key — never commit, operators obtain their own free key
minio-licence.key
.run
.full-review
.full-review
+113
View File
@@ -0,0 +1,113 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What TREK is
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo with three workspaces:
- **`shared/`** (`@trek/shared`) — Zod schemas that are the **single source of truth** for API contracts, consumed by both server and client. Also owns all i18n locale files. Must be built before server/client typecheck or run.
- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server (decorator-driven registration layer in `server/src/nest-mcp/`, formerly the `@trek/nest-mcp` workspace), sandboxed plugin runtime.
- **`client/`** (`@trek/client`) — React 19 + Vite + Zustand + Tailwind PWA, offline-first via Dexie/IndexedDB.
**`plugin-sdk/`** (`trek-plugin-sdk`) also lives in the repo but is **not a root workspace** — it has its own lockfile, is published to npm independently, and must ship standalone. Run its commands from `plugin-sdk/`, not the root.
Each package has its own `CLAUDE.md` with internals; this file covers the monorepo picture.
## Commands
Run from the repo root unless noted. **Use `npm run ... --workspace=<pkg>`, not `npm -w <pkg>`**.
```bash
npm run dev # build shared, then watch shared + server + client concurrently
npm run build # build shared → server → client (order matters)
npm run lint # lint all three workspaces
npm run format # prettier --write all three
```
Tests:
```bash
npm run test # all workspaces
npm run test --workspace=server # vitest run (server)
npm run test:unit --workspace=server # tests/unit only
npm run test:integration --workspace=server
npm run test:ws --workspace=server # websocket tests
npm run test:e2e # server e2e (boots Nest against temp SQLite)
npm run test:cov # coverage (lcov), all four packages incl. shared + plugin-sdk
cd client && npm run test # client vitest run
cd client && npm run e2e # Playwright
cd client && npm run lint:pages # enforce the Page pattern (see below)
cd client && npm run theme:lint # flag styling that bypasses appearance tokens (--strict in CI mindset)
npm run typecheck --workspace=server # tsc --noEmit (also client/shared)
```
Run a single test file or test:
```bash
npx vitest run tests/unit/nest/weather.controller.test.ts # (from server/ or client/)
npx vitest run -t "name of the test" # by test-name pattern
```
i18n parity (enforced in CI — every non-`en` locale must have identical file set + top-level keys):
```bash
npm run i18n:parity --workspace=shared # audit (exit 0)
npm run i18n:parity:strict --workspace=shared # CI gate (exit 1 on drift)
```
Dev ports: server listens on **3001**, Vite dev server proxies `/api`, `/ws`, `/uploads`, `/oauth`, etc. to `http://localhost:3001` (see `client/vite.config.js`).
## Server architecture
Nest owns everything: the strangler migration **completed 2026-08-10**, and the last seam — the pre-`app.init()` MCP/OAuth mount — moved behind the container 2026-08-11: **zero `*.bridge.ts` files remain**. Legacy `src/services/` is deleted (ESLint errors on any import of it) and all business logic lives in DI modules under `server/src/nest/<domain>/`. `server/src/nest/README.md` is the module blueprint. Every cron is Nest-owned (`server/src/nest/scheduling/`, `@nestjs/schedule`).
- **`bootstrap.ts` — `buildApp()`** is the single shared builder used by both production (`index.ts`) and the integration/e2e test harness. **Composition order is load-bearing**: global middleware → platform uploads → the discovery metadata middleware → static assets → the named body-parser wrappers are all registered on the raw Express instance **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and won't fall through to anything registered after init. The transport surface (`/api/health`, `/mcp`, `/.well-known/*` documents, `/oauth/*`) is Nest controllers/middleware. The SPA `index.html` fallback and the global error envelope are `APP_FILTER`s (`SpaFallbackFilter`, `TrekExceptionFilter`).
- **`/mcp` bodies are raw by design** — bootstrap's parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
- **Per-domain module shape** (one folder under `server/src/nest/<domain>/`): `<domain>.controller.ts` + `<domain>.service.ts` + `<domain>.module.ts`, registered in `app.module.ts`. **`weather/` is the reference implementation** — copy its shape. The Zod contract for the domain lives in `shared/src/<domain>/<domain>.schema.ts`.
- **Auth is default-deny**: `GlobalAuthGuard` + `MfaPolicyGuard` run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses unknown public routes. New domain logic goes in the module's `.service.ts` — never resurrect a `services/*`-style plain module.
- **Trip-scoped routes** must verify trip access (404 on no access) and the relevant permission (403) — the standard shape is `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler (a few domains still check inline; see `roadmap.md`) — and forward `X-Socket-Id` to the WebSocket broadcast so the originating client doesn't echo its own change. Never gate a multipart upload with a guard (ECONNRESET instead of 403 — check in the handler).
- **DB** (`server/src/db/`): `better-sqlite3`, WAL mode, FK on. `database.ts` initializes; `schema.ts` creates tables; `migrations.ts` + `seeds.ts` run on boot. In `NODE_ENV=test` each vitest worker gets an isolated `:memory:` DB; the Playwright harness uses `TREK_DB_FILE` for a throwaway file DB.
- **WebSocket** (`server/src/websocket.ts`): JWT-authed, room-per-trip (`tripId → Set<WebSocket>`), heartbeat keep-alive, per-connection rate limiting, served on `/ws`.
- **MCP** (`server/src/mcp/`): OAuth 2.1-authenticated MCP server exposing TREK to AI assistants (150+ tools in `tools/`, resources, scopes, session manager). See `MCP.md`.
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts` (mcp, packing, budget, documents, vacay, atlas, collab, journey, airtrail, llm_parsing, collections).
- **Plugins** (`server/src/nest/plugins/`): sandboxed third-party plugin runtime — one forked child process per plugin (`supervisor/`), permission-gated RPC (`host/rpc-host.ts` registers a handler only if the plugin holds the unlocking permission), install-time manifest/signature/egress checks (`install/`, `runtime/egress-policy.ts`). Author tooling lives in `plugin-sdk/`; the `trek-plugin-dev` skill covers plugin authoring.
## Client architecture
Offline-first, with a layered data flow. A Page never owns state directly:
- **Page pattern** (enforced by `lint:pages`, see `client/src/pages/PATTERN.md`): a `*Page.tsx` default export is a **wiring container** — it composes a co-located `use<Page>()` hook into JSX and must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` in its own body. Stateful logic lives in the hook.
- **State**: Zustand stores in `client/src/store/`. The big one is `tripStore.ts`, assembled from **slices** in `store/slices/` (places, packing, todo, budget, reservations, files, …). `slices/remoteEventHandler.ts` applies incoming WebSocket events to local state.
- **Data flow (offline-first)**: `store → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Repos check `navigator.onLine`: online they hit the REST API and upsert into Dexie; offline they read from Dexie. Writes go through `sync/mutationQueue.ts` — optimistic Dexie write, then on reconnect `flush()` replays REST with an `X-Idempotency-Key` header so retried writes don't double-apply. `sync/tripSyncManager.ts` and `sync/syncTriggers.ts` drive reconnection sync; `sync/tilePrefetcher.ts` caches map tiles.
- **Styling**: use the appearance tokens (`var(--token)` inline or `bg-[var(--...)]` Tailwind classes), never hardcoded color literals or numeric `fontSize` — `theme:lint` flags bypasses so user-chosen scheme/transparency/text-size keep working. Suppress intentional exceptions (map/PDF/brand colors) with a `theme-lint-disable` line comment.
- **PWA**: `vite-plugin-pwa` + Workbox caches tiles/API/uploads. `prebuild` generates icons.
## Shared contracts & i18n
- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema in `shared/src/<domain>/`, rebuild shared, then both server (validation + DTO types) and client (typed requests) pick it up.
- **i18n locale files live in `shared/src/i18n/<locale>/`**, one file per domain. `en/` is canonical. When you add/change a translation key, **add it to every locale** or `i18n:parity:strict` fails CI. The client resolves keys via `client/src/i18n/TranslationContext.tsx` (`t(key)`), which only reads top-level string keys.
## Project direction
These principles come out of a verified 2026 full-repo audit and shape how all new code should be written:
- **Protect the crown jewels.** The client's offline-first data core (repos + mutation queue + Dexie), the out-of-process plugin sandbox, the Zod contract layer, and the auth primitives are production-grade. Extend them; never route around them or rewrite them in anger.
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (historically Nest controllers over legacy `services/*` — that migration is done; on the client it's the long tail of feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip.
- **DI over global mutable module state.** The `db` proxy, `ws` singleton, and event-sink globals are what force `require()`-to-dodge-cycles and load-bearing boot ordering. New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state.
- **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (DI providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents of the same machinery.
- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not silently degrade. Never lower a quality gate (strict TS, lint severity, coverage, parity checks) to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`.
## Conventions (from CONTRIBUTING.md)
- **Target the `dev` branch** for PRs, not `main` (exception: `wiki/`-only changes).
- **Discuss first**: outside contributions must be pitched in the `#github-pr` Discord channel before any code is written — undiscussed PRs are closed.
- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and require a linked issue (`Closes #N`) for bug fixes or an approved feature-request discussion for features — no issue/discussion, no PR.
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). **Do not add Co-Authored-By or other tool-attribution trailers** to commit messages.
- One focused change per PR; no breaking changes; no unrelated reformatting/refactors. Tests required — the project holds **80%+ coverage** (the `src/nest/**` vitest coverage gate enforces ≥80%).
- When migrating/adding a route, **parity is law**: same URL, method, query/body, HTTP status, `Set-Cookie`, and JSON body — including bespoke error strings (e.g. reproduce `{ error: 'Admin only' }` exactly rather than relying on a generic guard message). Note Nest defaults POST to 201; add `@HttpCode(200)` where the legacy contract returned 200. Declare static sub-routes (`/reorder`, `/in-app/all`) **before** `:id` param routes.
## Reference docs
- `MCP.md` — MCP server/tools/scopes. `README.md` — deployment, env vars, reverse-proxy setup. `server/src/nest/README.md` — per-module blueprint and test layout (unit / parity / e2e).
+136
View File
@@ -0,0 +1,136 @@
# Contributor Covenant 3.0 Code of Conduct
## Our Pledge
We pledge to make our community welcoming, safe, and equitable for all.
We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all
individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics,
neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or
religion, national or social origin, socio-economic position, level of education, or other status. The same privileges
of participation are extended to everyone who participates in good faith and in accordance with this Covenant.
## Encouraged Behaviors
While acknowledging differences in social norms, we all strive to meet our community's expectations for positive
behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture,
background, or native language.
With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared
values, including:
1. Respecting the **purpose of our community**, our activities, and our ways of gathering.
2. Engaging **kindly and honestly** with others.
3. Respecting **different viewpoints** and experiences.
4. **Taking responsibility** for our actions and contributions.
5. Gracefully giving and accepting **constructive feedback**.
6. Committing to **repairing harm** when it occurs.
7. Behaving in other ways that promote and sustain the **well-being of our community**.
## Restricted Behaviors
We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are
violations of this Code of Conduct.
1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any
clear request to stop.
2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of
people.
3. **Stereotyping or discrimination.** Characterizing anyone’s personality or behavior on the basis of immutable
identities or traits.
4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or
purpose of the community.
5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their
permission.
6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group.
7. Behaving in other ways that **threaten the well-being** of our community.
### Other Restrictions
1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade
enforcement actions.
2. **Failing to credit sources.** Not properly crediting the sources of content you contribute.
3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the
community.
4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other
restricted behaviors.
## Reporting an Issue
Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict
represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help
avoid conflicts and minimize harm.
When an incident does occur, it is important to report it promptly. To report a possible violation, **send an email to
report@liketrek.com**.
Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They
will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing
witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as
possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried
out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon
resolution.
## Addressing and Repairing Harm
****
If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following
enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals
involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be
skipped.
1) Warning
1) Event: A violation involving a single incident or series of incidents.
2) Consequence: A private, written warning from the Community Moderators.
3) Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking
clarification on expectations.
2) Temporarily Limited Activities
1) Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a
more serious violation.
2) Consequence: A private, written warning with a time-limited cooldown period designed to underscore the
seriousness of the situation and give the community members involved time to process the incident. The cooldown
period may be limited to particular communication channels or interactions with particular community members.
3) Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and
impact, and being thoughtful about re-entering community spaces after the period is over.
3) Temporary Suspension
1) Event: A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a
single serious violation.
2) Consequence: A private written warning with conditions for return from suspension. In general, temporary
suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions.
3) Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for
return, and being thoughtful about how to reintegrate with the community when the suspension is lifted.
4) Permanent Ban
1) Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or
a violation so serious that the Community Moderators determine there is no way to keep the community safe with
this person as a member.
2) Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent
bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working
through other remedies has failed to change the behavior.
3) Repair: There is no possible repair in cases of this severity.
This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their
discretion and judgment, in keeping with the best interests of our community.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing
the community in public or other spaces. Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Attribution
This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available
at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/).
Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy
of this license,
visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/)
For answers to common questions about Contributor Covenant, see the FAQ
at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided
at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional
enforcement and community guideline resources can be found
at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement
ladder was inspired by the work of [Mozilla’s code of conduct team](https://github.com/mozilla/inclusion).
+3 -3
View File
@@ -10,7 +10,7 @@ Thanks for your interest in contributing! Please read these guidelines before op
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`. Exception: PRs that only modify files under `wiki/` may target any branch
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/liketrek/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
## Pull Requests
@@ -39,8 +39,8 @@ feat(budget): add CSV export for expenses
## Development Environment
See the [Developer Environment page](https://github.com/mauriceboe/TREK/wiki/Development-environment) for more information on setting up your development environment.
See the [Developer Environment page](https://github.com/liketrek/TREK/wiki/Development-environment) for more information on setting up your development environment.
## More Details
See the [Contributing wiki page](https://github.com/mauriceboe/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
See the [Contributing wiki page](https://github.com/liketrek/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
+33 -25
View File
@@ -46,26 +46,20 @@ COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY server/package.json ./server/
# better-sqlite3 native addon requires build tools (purged after compile).
# kitinerary-extractor for booking-confirmation import:
# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck)
# arm64 — apt package (KDE publishes no arm64 static binary)
# The trailing chown runs in this layer on purpose: it covers the manifests and
# the freshly installed node_modules while they are already part of this layer's
# changeset, so it costs nothing. Everything copied after this point carries
# --chown=node:node for the same reason — a recursive chown in a later layer
# would copy up every inode it touches and duplicate the whole tree in the image.
RUN apt-get update && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \
libkitinerary-bin && \
npm ci --workspace=server --omit=dev && \
ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then \
wget -qO /tmp/ki.tgz https://cdn.kde.org/ci-builds/pim/kitinerary/release-26.04/linux/kitinerary-extractor-x86_64-26.04.2.tgz && \
echo "ba5cfb4a2353157c8f54cbeaea0097c5bf2c3a810e0342f63d6e524826176628 /tmp/ki.tgz" | sha256sum -c && \
tar -xz -C /usr/local -f /tmp/ki.tgz bin/kitinerary-extractor share/locale && \
rm /tmp/ki.tgz; \
else \
apt-get install -y --no-install-recommends libkitinerary-bin && \
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
fi && \
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
apt-get purge -y python3 build-essential && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \
chown -R node:node /app
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
@@ -77,28 +71,42 @@ ENV QT_QPA_PLATFORM=offscreen
# Override with KITINERARY_EXTRACTOR_PATH if you install it elsewhere.
ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
COPY --from=server-builder /app/server/dist ./server/dist
COPY --chown=node:node --from=server-builder /app/server/dist ./server/dist
# Runtime data assets read from server/assets at runtime: airports.json (flight
# transport search) and atlas/*.geojson.gz (Atlas country/region map). The build
# only emits dist, so these must be copied explicitly or the features silently
# degrade to empty in the image.
COPY --from=server-builder /app/server/assets ./server/assets
COPY --chown=node:node --from=server-builder /app/server/assets ./server/assets
# The in-app help pages (/help) read this straight from disk at runtime, so the
# docs always match the version running. Without it, wikiService falls back to
# fetching the GitHub wiki, which tracks main and needs network access.
COPY --chown=node:node wiki ./wiki
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
COPY server/tsconfig.json ./server/
COPY --chown=node:node server/tsconfig.json ./server/
# Encryption-key rotation is run on demand via tsx (a prod dep) straight from the
# raw .ts source — it never enters dist, so it must be copied in explicitly or
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY --from=client-builder /app/client/dist ./server/public
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
COPY --chown=node:node server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
# Admin recovery script (node server/reset-admin.js) for locked-out installs.
COPY --chown=node:node server/reset-admin.js ./server/reset-admin.js
COPY --chown=node:node --from=shared-builder /app/shared/dist ./shared/dist
COPY --chown=node:node --from=client-builder /app/client/dist ./server/public
COPY --chown=node:node --from=client-builder /app/client/public/fonts ./server/public/fonts
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
# journey/ and places/ must be listed here and in server/src/index.ts (#1762) —
# a dir created lazily on first upload needs write permission on the uploads
# mount point itself, which fails with EACCES when the bind-mounted host dir
# isn't writable by node. Only paths this layer creates are chowned; anything
# already in the image arrived node-owned via --chown above.
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars \
/app/uploads/photos /app/uploads/journey /app/uploads/places && \
ln -s /app/uploads /app/server/uploads && \
ln -s /app/data /app/server/data && \
chown -R node:node /app
chown -R node:node /app/data /app/uploads && \
chown -h node:node /app/server/uploads /app/server/data
ENV NODE_ENV=production
ENV NODE_USE_ENV_PROXY=1
ENV PORT=3000
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
+1 -575
View File
@@ -1,577 +1,3 @@
# MCP Integration
TREK includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that lets AI
assistants — such as Claude Desktop, Cursor, or any MCP-compatible client — read and modify your trip data through a
structured API.
> **Note:** MCP is an addon that must be enabled by your TREK administrator before it becomes available.
## Table of Contents
- [Setup](#setup)
- [Option A: OAuth 2.1 (recommended)](#option-a-oauth-21-recommended)
- [Option B: Static API Token (deprecated)](#option-b-static-api-token-deprecated)
- [Authentication](#authentication)
- [OAuth Scopes](#oauth-scopes)
- [Limitations & Important Notes](#limitations--important-notes)
- [Resources (read-only)](#resources-read-only)
- [Tools (read-write)](#tools-read-write)
- [Compound Tools](#compound-tools)
- [Prompts](#prompts)
- [Example](#example)
---
## Setup
### 1. Enable the MCP addon (admin)
An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp`
endpoint returns `404` and the MCP section does not appear in user settings.
### 2. Connect your MCP client
#### Option A: OAuth 2.1 (recommended)
MCP clients that support OAuth 2.1 (such as Claude Desktop via `mcp-remote`) authenticate automatically. No token
management required — just provide the server URL:
```json
{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"https://your-trek-instance.com/mcp"
]
}
}
}
```
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
**What happens automatically:**
1. The client fetches `/.well-known/oauth-protected-resource` (RFC 9728) to discover the authorization server and bind the `/mcp` endpoint.
2. The client fetches `/.well-known/oauth-authorization-server` for the full AS metadata.
3. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
4. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
5. The client receives a short-lived access token audience-bound to `/mcp` (RFC 8707) and a rotating refresh token — no re-authorization needed.
> **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth
> discovery to work correctly.
**For more control over scopes or to use confidential client mode**, pre-create an OAuth client in
**Settings > Integrations > MCP > OAuth Clients** before connecting. Clients created there have a client secret
(`trekcs_` prefix) and fixed scopes that you define up front.
#### Option B: Static API Token (deprecated)
> **Deprecated:** Static API tokens will stop working in a future version. Migrate to OAuth 2.1 above.
1. Go to **Settings > Integrations > MCP** and create an API token.
2. Click **Create New Token**, give it a name, and **copy the token immediately** — it is shown only once.
3. Add it to your `claude_desktop_config.json`:
```json
{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"https://your-trek-instance.com/mcp",
"--header",
"Authorization: Bearer trek_your_token_here"
]
}
}
}
```
Static tokens grant full access to all tools and resources (no scope restrictions). Sessions authenticated with a
static token will receive deprecation warnings in the AI client via server instructions and tool results.
Each user can create up to **10 static tokens**.
---
## Authentication
TREK's MCP server supports three authentication methods. OAuth 2.1 is the recommended path for all external clients.
| Method | Token prefix | Access level | TTL | Notes |
|--------|-------------|-------------|-----|-------|
| **OAuth 2.1** | `trekoa_` | Scoped (per-consent) | 1 hour | Recommended. Automatically refreshed via 30-day rolling refresh tokens (`trekrf_` prefix). Replay-detected rotation — replayed tokens cascade-revoke the entire chain. |
| **Static API token** | `trek_` | Full access | No expiry | **Deprecated.** Triggers deprecation warnings in AI clients. Will be removed in a future release. |
| **Web session JWT** | — | Full access | Session-based | Used internally by the TREK web UI. Not intended for external clients. |
All methods require the `Authorization: Bearer <token>` header (strict scheme enforcement — `Bearer` required).
---
## OAuth Scopes
When connecting via OAuth 2.1, you grant specific scopes during the consent step. TREK registers only the MCP tools
that match your granted scopes for that session.
| Scope | Permission | Group |
|-------|-----------|-------|
| `trips:read` | View trips & itineraries | Trips |
| `trips:write` | Edit trips & itineraries | Trips |
| `trips:delete` | Delete trips (irreversible) | Trips |
| `trips:share` | Manage share links | Trips |
| `places:read` | View places & map data | Places |
| `places:write` | Manage places | Places |
| `atlas:read` | View Atlas | Atlas |
| `atlas:write` | Manage Atlas | Atlas |
| `packing:read` | View packing lists | Packing |
| `packing:write` | Manage packing lists | Packing |
| `todos:read` | View to-do lists | To-dos |
| `todos:write` | Manage to-do lists | To-dos |
| `budget:read` | View budget | Budget |
| `budget:write` | Manage budget | Budget |
| `reservations:read` | View reservations | Reservations |
| `reservations:write` | Manage reservations | Reservations |
| `collab:read` | View collaboration | Collaboration |
| `collab:write` | Manage collaboration | Collaboration |
| `notifications:read` | View notifications | Notifications |
| `notifications:write` | Manage notifications | Notifications |
| `vacay:read` | View vacation plans | Vacation |
| `vacay:write` | Manage vacation plans | Vacation |
| `geo:read` | Maps & geocoding | Geo |
| `weather:read` | Weather forecasts | Weather |
| `journey:read` | View journeys | Journey |
| `journey:write` | Manage journeys | Journey |
| `journey:share` | Manage journey share links | Journey |
**Scope rules:**
- A `:write` scope implies `:read` access for the same group (e.g. `budget:write` also grants budget read access).
- Any `trips:*` scope (`trips:read`, `trips:write`, `trips:delete`, or `trips:share`) grants trip read access.
- Any `journey:*` scope (`journey:read`, `journey:write`, or `journey:share`) grants journey read access.
- `list_trips` and `get_trip_summary` are **always available** regardless of scopes — they are navigation tools.
- Static tokens and web session JWTs have full access to all tools (equivalent to all scopes).
- Addon-gated tools (Atlas, Collab, Vacay, Journey) require both the relevant scope **and** the addon to be enabled.
---
## Limitations & Important Notes
| Limitation | Details |
|-----------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|
| **Admin activation required** | The MCP addon must be enabled by an admin before any user can access it. |
| **Per-user scoping** | Each MCP session is scoped to the authenticated user. You can only access trips you own or are a member of. |
| **No image uploads** | Cover images cannot be set through MCP. Use the web UI to upload trip covers. |
| **Reservations are created as pending** | When the AI creates a reservation, it starts with `pending` status. You must confirm it manually or ask the AI to set the status to `confirmed`. |
| **Demo mode restrictions** | If TREK is running in demo mode, all write operations through MCP are blocked. |
| **Rate limiting** | 300 requests per minute per user (configurable via `MCP_RATE_LIMIT`). Exceeding this returns a `429` error. |
| **Per-client rate limiting** | Rate limits are tracked per user-client pair, so each OAuth client has its own independent rate limit window. |
| **Session limits** | Maximum 20 concurrent MCP sessions per user (configurable via `MCP_MAX_SESSION_PER_USER`). Sessions expire after 1 hour of inactivity. |
| **Token limits** | Maximum 10 static API tokens per user. Maximum 10 OAuth clients per user. |
| **Token revocation** | Deleting a static token or revoking an OAuth session immediately terminates all active MCP sessions for that token/client. |
| **OAuth scope enforcement** | Only tools matching your granted OAuth scopes are registered in the session. Calling an out-of-scope tool returns an error. |
| **Addon toggle invalidation** | When an admin enables or disables an addon, all active MCP sessions are invalidated and must be re-established. |
| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. |
| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay, Journey) is enabled by an admin. |
---
## Resources (read-only)
Resources provide read-only access to your TREK data. MCP clients can read these to understand the current state before
making changes.
### Core Resources
| Resource | URI | Description |
|-----------------------|-------------------------------------------------|---------------------------------------------------------------------------------------|
| Trips | `trek://trips` | All trips you own or are a member of |
| Trip Detail | `trek://trips/{tripId}` | Single trip with metadata and member count |
| Days | `trek://trips/{tripId}/days` | Days of a trip with their assigned places |
| Places | `trek://trips/{tripId}/places` | All places/POIs saved in a trip. Supports `?assignment=all\|unassigned\|assigned` |
| Budget | `trek://trips/{tripId}/budget` | Budget and expense items |
| Budget Per-Person | `trek://trips/{tripId}/budget/per-person` | Per-person totals and split breakdown |
| Budget Settlement | `trek://trips/{tripId}/budget/settlement` | Suggested transactions to settle who owes whom |
| Packing | `trek://trips/{tripId}/packing` | Packing checklist |
| Packing Bags | `trek://trips/{tripId}/packing/bags` | Packing bags with their assigned members |
| Reservations | `trek://trips/{tripId}/reservations` | Flights, hotels, restaurants, etc. |
| Day Notes | `trek://trips/{tripId}/days/{dayId}/notes` | Notes for a specific day |
| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details |
| Members | `trek://trips/{tripId}/members` | Owner and collaborators |
| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes |
| To-Dos | `trek://trips/{tripId}/todos` | To-do items ordered by position |
| Categories | `trek://categories` | Available place categories (for use when creating places) |
| Bucket List | `trek://bucket-list` | Your personal travel bucket list |
| Visited Countries | `trek://visited-countries` | Countries marked as visited in Atlas |
| Notifications | `trek://notifications/in-app` | Your in-app notifications (most recent 50, unread first) |
### Addon-Gated Resources
These resources are only available when the corresponding addon is enabled by an admin.
| Resource | URI | Addon | Description |
|-----------------------|-------------------------------------------------|----------|---------------------------------------------------------------------|
| Atlas Stats | `trek://atlas/stats` | Atlas | Visited country counts and continent breakdown |
| Atlas Regions | `trek://atlas/regions` | Atlas | Manually visited sub-country regions |
| Collab Polls | `trek://trips/{tripId}/collab/polls` | Collab | All polls for a trip with vote counts per option |
| Collab Messages | `trek://trips/{tripId}/collab/messages` | Collab | Most recent 100 chat messages for a trip |
| Vacay Plan | `trek://vacay/plan` | Vacay | Full snapshot of your active vacation plan (members, years, config) |
| Vacay Entries | `trek://vacay/entries/{year}` | Vacay | All vacation day entries for the active plan and a specific year |
| Vacay Holidays | `trek://vacay/holidays/{year}` | Vacay | Public holidays for the plan's configured region and year |
| Journeys | `trek://journeys` | Journey | All journeys owned or contributed to by the current user |
| Journey Detail | `trek://journeys/{journeyId}` | Journey | Single journey with entries, contributors, and linked trips |
| Journey Entries | `trek://journeys/{journeyId}/entries` | Journey | All entries in a journey (date, text, mood, linked trip) |
| Journey Contributors | `trek://journeys/{journeyId}/contributors` | Journey | Contributors (owner and collaborators) of a journey |
---
## Tools (read-write)
TREK exposes tools organized by feature area. Use `get_trip_summary` as a starting point — it returns everything about a
trip in a single call.
### Trip Summary
| Tool | Description |
|--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget, packing, reservations, collab notes, to-dos, and poll/message counts. Use this as your context loader. |
### Compound Tools
Compound tools collapse common multi-step workflows into a single atomic call. Each one wraps two sequential operations in a database transaction — if the second step fails, the first is rolled back automatically.
> **When to use:** Only use compound tools when the place or item does not yet exist. If it already exists, call the individual tools (`assign_place_to_day`, `create_accommodation`, `set_budget_item_members`) directly.
| Tool | Wraps | Description |
|---|---|---|
| `create_and_assign_place` | `create_place` + `assign_place_to_day` | Create a new place and immediately assign it to a specific day. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `dayId` and optional `assignment_notes`. Returns `{ place, assignment }`. |
| `create_place_accommodation` | `create_place` + `create_accommodation` | Create a new place and immediately book it as an accommodation for a date range. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `start_day_id`, `end_day_id`, `check_in`, `check_out`, `confirmation`, and `accommodation_notes`. Also auto-creates a linked hotel reservation. Returns `{ place, accommodation }`. |
| `create_budget_item_with_members` | `create_budget_item` + `set_budget_item_members` | Create a budget item and optionally set which members are splitting it. Accepts all `create_budget_item` fields plus an optional `userIds` array. If `userIds` is omitted or empty, behaves identically to `create_budget_item`. Returns `{ item }` with members populated. |
**Scope requirements** match the underlying tools: `places:write` for `create_and_assign_place`, `trips:write` for `create_place_accommodation`, `budget:write` for `create_budget_item_with_members` (Budget addon required).
---
### Trips
| Tool | Description |
|----------------------|---------------------------------------------------------------------------------------------|
| `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. |
| `create_trip` | Create a new trip with title, dates, currency. Days are auto-generated from the date range. |
| `update_trip` | Update a trip's title, description, dates, or currency. |
| `delete_trip` | Delete a trip. **Owner only.** |
| `list_trip_members` | List the owner and all collaborators of a trip. |
| `add_trip_member` | Add a user to a trip by username or email. **Owner only.** |
| `remove_trip_member` | Remove a collaborator from a trip. **Owner only.** |
| `copy_trip` | Duplicate a trip (days, places, itinerary, packing, budget, reservations). Packing items are reset to unchecked. |
| `export_trip_ics` | Export the trip itinerary and reservations as iCalendar (`.ics`) text for calendar apps. |
| `get_share_link` | Get the current public share link for a trip and its permission flags. |
| `create_share_link` | Create or update the public share link with configurable visibility flags (map, bookings, packing, budget, collab). |
| `delete_share_link` | Revoke the public share link for a trip. |
### Places
> To create a place and assign it to a day in one call, use [`create_and_assign_place`](#compound-tools).
| Tool | Description |
|------------------|--------------------------------------------------------------------------------------------------|
| `list_places` | List places/POIs in a trip, optionally filtered by assignment status, category, tag, or search. |
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone, and optional `google_place_id` / `osm_id` for opening hours. |
| `update_place` | Update any field of an existing place including transport mode, timing, and price. |
| `delete_place` | Remove a place from a trip. |
| `bulk_delete_places` | Delete multiple places at once by ID. Removes all day assignments as well. **Cannot be undone.** |
| `import_places_from_url` | Import all places from a publicly shared Google Maps or Naver Maps list URL. |
| `list_categories` | List all available place categories with id, name, icon and color. |
| `search_place` | Search for a real-world place by name or address. Returns `osm_id` and `google_place_id` for use in `create_place`. |
### Day Planning
| Tool | Description |
|-----------------------------|--------------------------------------------------------------------------------------|
| `update_day` | Set or clear a day's title (e.g. "Arrival in Paris", "Free day"). |
| `create_day` | Add a new day to a trip with optional date and notes. |
| `delete_day` | Delete a day from a trip. |
| `assign_place_to_day` | Pin a place to a specific day in the itinerary. |
| `unassign_place` | Remove a place assignment from a day. |
| `reorder_day_assignments` | Reorder places within a day by providing assignment IDs in the desired order. |
| `update_assignment_time` | Set start/end times for a place assignment (e.g. "09:00" – "11:30"). Pass `null` to clear. |
| `move_assignment` | Move a place assignment to a different day. |
| `get_assignment_participants`| Get the list of users participating in a specific place assignment. |
| `set_assignment_participants`| Set participants for a place assignment (replaces current list). |
### Accommodations
> To create a place and book it as an accommodation in one call, use [`create_place_accommodation`](#compound-tools).
| Tool | Description |
|------------------------|------------------------------------------------------------------------------------------|
| `create_accommodation` | Add an accommodation (hotel, Airbnb, etc.) linked to a place and a check-in/out date range. |
| `update_accommodation` | Update fields on an existing accommodation (dates, times, confirmation, notes). |
| `delete_accommodation` | Delete an accommodation record from a trip. |
### Transport
Transport bookings (flights, trains, cars, cruises) support multi-stop `endpoints[]` — each endpoint has a `role` (`from`/`to`/`stop`), name, optional IATA `code` (for flights), coordinates, timezone, and local time. Use `search_airports` to resolve airport names to IATA codes before creating a flight.
| Tool | Description |
|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| `create_transport` | Create a transport booking (`flight`, `train`, `car`, `cruise`) with optional endpoints, departure/arrival times, and confirmation details. Created as pending. |
| `update_transport` | Update an existing transport booking. Pass `endpoints[]` to replace the full stop list. Use `status: "confirmed"` to confirm. |
| `delete_transport` | Delete a transport booking from a trip. |
### Reservations
For flights, trains, cars, and cruises, use the **Transport** tools above. Reservations cover all other booking types.
| Tool | Description |
|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
| `create_reservation` | Create a pending reservation. Supports hotels, restaurants, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
| `reorder_reservations` | Update the display order of reservations (and transports) within a day. |
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
### Budget
> To create a budget item and set its members in one call, use [`create_budget_item_with_members`](#compound-tools).
| Tool | Description |
|----------------------------|---------------------------------------------------------------------------------------|
| `create_budget_item` | Add an expense with name, category, and price. |
| `update_budget_item` | Update an expense's details, split (persons/days), or notes. |
| `delete_budget_item` | Remove a budget item. |
| `set_budget_item_members` | Set which trip members are splitting a budget item (replaces current member list). |
| `toggle_budget_member_paid`| Mark or unmark a member as having paid their share of a budget item. |
### Packing
| Tool | Description |
|-------------------------------|-----------------------------------------------------------------------------------|
| `create_packing_item` | Add an item to the packing checklist with optional category. |
| `update_packing_item` | Rename an item or change its category. |
| `toggle_packing_item` | Check or uncheck a packing item. |
| `delete_packing_item` | Remove a packing item. |
| `reorder_packing_items` | Set the display order of packing items within a trip. |
| `bulk_import_packing` | Import multiple packing items at once from a list (with optional quantity). |
| `apply_packing_template` | Apply a saved packing template to a trip (adds items from the template). |
| `save_packing_template` | Save the current packing list as a reusable template. |
| `list_packing_bags` | List all packing bags for a trip. |
| `create_packing_bag` | Create a new packing bag (e.g. "Carry-on", "Checked bag"). |
| `update_packing_bag` | Rename or recolor a packing bag. |
| `delete_packing_bag` | Delete a packing bag (items are unassigned, not deleted). |
| `set_bag_members` | Assign trip members to a packing bag. |
| `get_packing_category_assignees` | Get which trip members are assigned to each packing category. |
| `set_packing_category_assignees` | Assign trip members to a packing category. |
### Day Notes
| Tool | Description |
|-------------------|------------------------------------------------------------------------|
| `create_day_note` | Add a note to a specific day with optional time label and emoji icon. |
| `update_day_note` | Edit a day note's text, time, or icon. |
| `delete_day_note` | Remove a note from a day. |
### To-Dos
| Tool | Description |
|-------------------------------|---------------------------------------------------------------------------------------------------|
| `list_todos` | List all to-do items for a trip, ordered by position. |
| `create_todo` | Create a to-do item with name, category, due date, description, assignee, and priority. |
| `update_todo` | Update an existing to-do item. Pass `null` to clear nullable fields. |
| `toggle_todo` | Mark a to-do item as done or undone. |
| `delete_todo` | Delete a to-do item. |
| `reorder_todos` | Reorder to-do items within a trip by providing a new ordered list of IDs. |
| `get_todo_category_assignees` | Get the default assignees configured per to-do category for a trip. |
| `set_todo_category_assignees` | Set default assignees for a to-do category. Pass an empty array to clear. |
### Tags
| Tool | Description |
|--------------|--------------------------------------------------------------------------|
| `list_tags` | List all tags belonging to the current user. |
| `create_tag` | Create a new tag (user-scoped label for places) with optional hex color. |
| `update_tag` | Update the name or color of an existing tag. |
| `delete_tag` | Delete a tag (removes it from all places it was attached to). |
### Notifications
| Tool | Description |
|---------------------------------|------------------------------------------------------|
| `list_notifications` | List in-app notifications with pagination and unread filter. |
| `get_unread_notification_count` | Get the count of unread in-app notifications. |
| `mark_notification_read` | Mark a single notification as read. |
| `mark_notification_unread` | Mark a single notification as unread. |
| `mark_all_notifications_read` | Mark all notifications as read. |
### Maps & Weather
| Tool | Description |
|-----------------------|-----------------------------------------------------------------------------------------------------|
| `search_place` | Search for a real-world place by name/address and get coordinates, `osm_id`, and `google_place_id`. |
| `get_place_details` | Fetch detailed information (hours, photos, ratings) about a place by its Google Place ID. |
| `reverse_geocode` | Get a human-readable address for given coordinates. |
| `resolve_maps_url` | Resolve a Google Maps share URL to coordinates and place name. |
| `get_weather` | Get weather forecast for a location and date. |
| `get_detailed_weather`| Get hourly/detailed weather forecast for a location and date. |
### Airports
| Tool | Description |
|-------------------|-------------------------------------------------------------------------------------------------------------------|
| `search_airports` | Search for airports by name, city, or IATA code. Returns IATA code, name, city, country, coordinates, timezone. |
| `get_airport` | Look up a single airport by IATA code (e.g. `"ZRH"`, `"AMS"`, `"CDG"`). |
### Collab Notes _(Collab addon required)_
| Tool | Description |
|----------------------|-------------------------------------------------------------------------------------------------|
| `create_collab_note` | Create a shared note visible to all trip members. Supports title, content, category, and color. |
| `update_collab_note` | Edit a collab note's content, category, color, or pin status. |
| `delete_collab_note` | Delete a collab note. |
### Collab Polls & Chat _(Collab addon required)_
| Tool | Description |
|-----------------------|------------------------------------------------------------------------------------------|
| `list_collab_polls` | List all polls for a trip. |
| `create_collab_poll` | Create a new poll with a question, options, optional multiple choice, and deadline. |
| `vote_collab_poll` | Vote on a poll option (or remove vote if already voted). |
| `close_collab_poll` | Close a poll so no more votes can be cast. |
| `delete_collab_poll` | Delete a poll and all its votes. |
| `list_collab_messages`| List chat messages for a trip (most recent 100, supports pagination via `before`). |
| `send_collab_message` | Send a chat message to a trip's collab channel, with optional reply threading. |
| `delete_collab_message`| Delete a chat message (own messages only). |
| `react_collab_message`| Toggle a reaction emoji on a chat message. |
### Bucket List _(Atlas addon required)_
| Tool | Description |
|---------------------------|--------------------------------------------------------------------------------------------|
| `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates and country code. |
| `delete_bucket_list_item` | Remove an item from your bucket list. |
### Atlas _(Atlas addon required)_
| Tool | Description |
|--------------------------|---------------------------------------------------------------------------------|
| `mark_country_visited` | Mark a country as visited using its ISO 3166-1 alpha-2 code (e.g. "FR", "JP"). |
| `unmark_country_visited` | Remove a country from your visited list. |
### Atlas Extended _(Atlas addon required)_
| Tool | Description |
|----------------------------|------------------------------------------------------------------------------|
| `get_atlas_stats` | Get atlas statistics — visited country counts, region counts, continent breakdown. |
| `list_visited_regions` | List all manually visited sub-country regions for the current user. |
| `mark_region_visited` | Mark a sub-country region as visited (e.g. ISO code "US-CA"). |
| `unmark_region_visited` | Remove a region from the visited list. |
| `get_country_atlas_places` | Get places saved in the user's atlas for a specific country. |
| `update_bucket_list_item` | Update a bucket list item (name, notes, coordinates, target date). |
### Vacay _(Vacay addon required)_
| Tool | Description |
|----------------------------|---------------------------------------------------------------------------------------|
| `get_vacay_plan` | Get the current user's active vacation plan (own or joined). |
| `update_vacay_plan` | Update vacation plan settings (weekend blocking, holidays, carry-over). |
| `set_vacay_color` | Set the current user's color in the vacation plan calendar. |
| `get_available_vacay_users`| List users who can be invited to the current vacation plan. |
| `send_vacay_invite` | Invite a user to join the vacation plan by their user ID. |
| `accept_vacay_invite` | Accept a pending invitation to join another user's vacation plan. |
| `decline_vacay_invite` | Decline a pending vacation plan invitation. |
| `cancel_vacay_invite` | Cancel an outgoing invitation (owner cancels an invite they sent). |
| `dissolve_vacay_plan` | Dissolve the shared plan — all members return to their own individual plan. |
| `list_vacay_years` | List calendar years tracked in the current vacation plan. |
| `add_vacay_year` | Add a calendar year to the vacation plan. |
| `delete_vacay_year` | Remove a calendar year from the vacation plan. |
| `get_vacay_entries` | Get all vacation day entries for the active plan and a specific year. |
| `toggle_vacay_entry` | Toggle a day on or off as a vacation day for the current user. |
| `toggle_company_holiday` | Toggle a date as a company holiday for the whole plan. |
| `get_vacay_stats` | Get vacation statistics for a specific year (days used, remaining, carried over). |
| `update_vacay_stats` | Update the vacation day allowance for a specific user and year. |
| `add_holiday_calendar` | Add a public holiday calendar (by region code) to the vacation plan. |
| `update_holiday_calendar` | Update label or color for a holiday calendar. |
| `delete_holiday_calendar` | Remove a holiday calendar from the vacation plan. |
| `list_holiday_countries` | List countries available for public holiday calendars. |
| `list_holidays` | List public holidays for a country and year. |
### Journey _(Journey addon required)_
| Tool | Description |
|-----------------------------------|------------------------------------------------------------------------------------------------------------|
| `list_journeys` | List all journeys owned or contributed to by the current user. |
| `get_journey` | Get a full snapshot of a journey: metadata, entries, contributors, and linked trips. |
| `create_journey` | Create a new journey with title, optional subtitle, and an initial list of trip IDs. |
| `update_journey` | Update a journey's title, subtitle, or status. |
| `delete_journey` | Delete a journey. |
| `add_journey_trip` | Link an existing trip to a journey. |
| `remove_journey_trip` | Remove a trip from a journey. |
| `list_journey_entries` | List all entries in a journey (date, text, mood, linked trip). |
| `create_journey_entry` | Add an entry to a journey with optional title, body text, date, linked trip, and sort order. |
| `update_journey_entry` | Edit a journey entry's title, body, date, or mood. |
| `delete_journey_entry` | Remove an entry from a journey. |
| `reorder_journey_entries` | Reorder entries in a journey by providing the new ordered list of entry IDs. |
| `list_journey_contributors` | List the contributors of a journey (owner and invited editors/viewers). |
| `add_journey_contributor` | Invite a user to a journey with `editor` or `viewer` role. |
| `update_journey_contributor_role` | Change a contributor's role between `editor` and `viewer`. |
| `remove_journey_contributor` | Remove a contributor from a journey. |
| `update_journey_preferences` | Update display preferences for a journey (e.g. hide skeleton entries). |
| `get_journey_suggestions` | Get suggested trips to add to journeys (based on recent trip history). |
| `list_journey_available_trips` | List all trips available to the current user for linking to a journey. |
| `get_journey_share_link` | Get the current public share link for a journey. |
| `create_journey_share_link` | Create or update the public share link for a journey. |
| `delete_journey_share_link` | Revoke the public share link for a journey. |
---
## Prompts
MCP prompts are pre-built context loaders your AI client can invoke to get a structured starting point for common tasks.
| Prompt | Description |
|----------------------|---------------------------------------------------------------------------------|
| `trip-summary` | Load a formatted summary of a trip (dates, members, days, budget, packing, reservations) before planning or modifying it. |
| `packing-list` | Get a formatted packing checklist for a trip, grouped by category. |
| `budget-overview` | Get a formatted budget summary with totals by category and per-person cost. |
| `token_auth_notice` | Static token deprecation notice and migration guide. Only available in sessions authenticated with a legacy `trek_` token. |
---
## Example
Conversation with Claude: https://claude.ai/share/51572203-6a4d-40f8-a6bd-eba09d4b009d
Initial prompt (1st message):
```
I'd like to plan a week-long trip to Kyoto, Japan, arriving April 5 2027
and leaving April 11 2027. It's cherry blossom season so please keep that
in mind when picking spots.
Before writing anything to TREK, do some research: look up what's worth
visiting, figure out a logical day-by-day flow (group nearby spots together
to avoid unnecessary travel), find a well-reviewed hotel in a central
neighbourhood, and think about what kind of food and restaurant experiences
are worth including.
Once you have a solid plan, write the whole thing to TREK:
- Create the trip
- Add all the places you've researched with their real coordinates
- Build out the daily itinerary with sensible visiting times
- Book the hotel as a reservation and link it properly to the accommodation days
- Add any notable restaurant reservations
- Put together a realistic budget in EUR
- Build a packing list suited to April in Kyoto
- Leave a pinned collab note with practical tips (transport, etiquette, money, etc.)
- Add a day note for each day with any important heads-up (early start, crowd
tips, booking requirements, etc.)
- Mark Japan as visited in my Atlas
Currency: CHF. Use get_trip_summary at the end and give me a quick recap
of everything that was added.
```
PDF of the generated trip: [./docs/TREK-Generated-by-MCP.pdf](./docs/TREK-Generated-by-MCP.pdf)
![trip](./docs/screenshot-trip-mcp.png)
Please refer to the [MCP wiki](https://github.com/liketrek/TREK/wiki/MCP-Overview) for more information.
+24 -2
View File
@@ -21,12 +21,34 @@ property names TREK consumes. Country borders (`admin0`) derive from the geoBoun
CGAZ composite; sub-national regions (`admin1`) derive from the per-country open
(gbOpen) release.
## OpenStreetMap — geocoding
## OpenStreetMap — geocoding, place details & descriptions
Atlas reverse-geocodes places via the **Nominatim** service. Geocoding data is
Atlas reverse-geocodes places via the **Nominatim** service, and place search,
details and descriptions come from Nominatim and **Overpass**. Data is
© OpenStreetMap contributors, licensed under the Open Database License (ODbL).
https://www.openstreetmap.org/copyright
## Wikimedia Commons & Wikipedia — place pictures & descriptions
Pictures offered for a place come from **Wikimedia Commons** (geosearch by
coordinate) and are cached locally rather than hotlinked. Descriptions can come
from **Wikipedia**, resolved from the place's OpenStreetMap `wikipedia` tag.
Commons files carry their own licence, most often **CC BY** or **CC BY-SA**. TREK
shows the author, the licence and a link to the file description page next to
every picture while it is being chosen, and keeps the author and licence with the
cached file so the credit stays visible afterwards. Wikipedia article text is
**CC BY-SA 4.0**. https://commons.wikimedia.org/wiki/Commons:Licensing
## Google Places — optional place data
When an administrator configures a Google Maps API key, place search, details,
pictures and editorial summaries can also come from the **Google Places API**,
subject to Google's terms. Photos carry the author attribution Google supplies
and no reusable licence; TREK displays that attribution and claims nothing
further. This path is off unless a key is configured, and each part of it has its
own switch in the admin panel. https://cloud.google.com/maps-platform/terms
## OurAirports — airport reference data
`server/assets/airports.json` is built from **OurAirports**
+150 -219
View File
@@ -1,17 +1,16 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.gif" />
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.gif" />
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.svg" />
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.svg" />
<img src="docs/logo-trek-dark.svg" alt="TREK" height="96" />
</picture>
<br />
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
<img src="docs/subtitle-dark.png" alt="Your trips. Your plan. Your server." height="28" />
<img src="docs/subtitle-dark.png" alt="your trip. your plan." height="28" />
</picture>
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
@@ -22,80 +21,84 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
&nbsp;
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
&nbsp;
<a href="https://sonarcloud.io/project/overview?id=liketrek_TREK"><img alt="Sonar Quality Gate" src="https://img.shields.io/sonar/quality_gate/liketrek_TREK?server=https%3A%2F%2Fsonarcloud.io&style=for-the-badge" /></a>
&nbsp;
<img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/liketrek/TREK/test.yml?branch=main&style=for-the-badge">
&nbsp;
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
&nbsp;
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-view-0EA5E9?style=for-the-badge" /></a>
<br />
&nbsp;
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
&nbsp;
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
<br />
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/trek?include_prereleases&style=flat-square&color=6B7280" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
<a href="https://github.com/mauriceboe/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/mauriceboe/TREK?style=flat-square&color=6B7280" /></a>
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=flat-square&color=6B7280" /></a>
</div>
---
<div align="center">
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.webp" alt="TREK, a tour of the app" width="100%" />
</div>
<br />
<div align="center">
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Costs · expense splitting" width="49%" /></a>
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Trip planner · day plan and route" width="49%" /></a>
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
</div>
---
## What you get
<picture>
<source media="(max-width: 700px)" srcset="docs/tiles/grid-mobile.svg" />
<img src="docs/tiles/grid-desktop.svg" alt="TREK feature tiles" width="100%" />
<source media="(prefers-color-scheme: dark)" srcset="docs/tiles/grid-desktop-dark.svg" />
<img src="docs/tiles/grid-desktop.svg" alt="Plan, track, share" width="100%" />
</picture>
<details>
<summary><b>See all features</b></summary>
<br />
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Vacay and Atlas ship on; Journey, Collections, MCP, AI Parsing and AirTrail ship off and are marked below.
<table>
<tr>
<td width="50%" valign="top">
#### 🧭 Trip planning
#### 🧭 Planning
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
- **Route optimisation** — auto-sort places and export to Google Maps
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
- **Category filter** — show only matching pins on the map
- **Day plans**: drag places between days and reorder inside a day, with undo. Notes and bookings drag the same way, and a map marker drops straight onto a day
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token), with clustering, photo markers and route lines. 3D buildings and terrain are Mapbox only
- **Place search**: Google Places when a key is set (photos, ratings, opening hours), otherwise OpenStreetMap with no key
- **Place enrichment**: descriptions, facts, hours and photo candidates from OpenStreetMap, Wikipedia, Wikidata and Wikimedia Commons
- **POI explore**: pull OpenStreetMap POIs by category for the current viewport over Overpass
- **Import**: shared Google Maps and Naver Maps lists, plus GPX, KML and KMZ files
- **Export**: GPX of a trip's places and tracks, and an ICS feed per trip or across all of them
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotel anchors stay put), driving, walking or cycling profiles over OSRM, then open it in Google Maps or CoMaps
- **Public transport**: door-to-door itineraries over Transitous
- **Weather**: 16-day forecast from Open-Meteo, no key. Dates outside that window read the archive for the same date instead
- **Day notes**: markdown body with an icon and a colour, reordered by drag and drop or moved to another day
- **Trip dates**: move a trip and the days re-date themselves, either dragging the bookings along or re-anchoring them. Trips also copy and archive
</td>
<td width="50%" valign="top">
#### 🧳 Travel management
#### 🧳 Bookings and money
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency
- **Packing lists** — categories, templates, user assignment, progress tracking
- **Bag tracking** — optional weight tracking with iOS-style distribution
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
- **PDF export** — full trip plan as PDF with cover page, images, notes
- **Reservations**: 16 booking types with status, confirmation code, travellers and attached files
- **Flights and trains**: several legs with stopovers, per-leg times and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
- **Accommodation**: a stay spans a range of days with a check-in window, and shows on every night it covers
- **Booking import**: EML, PDF, PKPass, HTML and TXT confirmations through [KItinerary](https://invent.kde.org/pim/kitinerary). Needs the `kitinerary-extractor` binary, which ships in the Docker image
- **AirTrail** (off by default): link a self-hosted AirTrail instance to import flights as reservations and keep them in sync
- **Costs**: split expenses in integer cents with equal or custom shares, several payers per expense, settle-up suggestions, a settlement log, and CSV export
- **Currencies**: a currency per expense with the rate frozen at entry. Rates come from Frankfurter, no key
- **Packing lists**: categories, admin-managed templates, assignees, three visibility tiers, and a packed-of-total line. Bags with weight roll-up are a separate admin switch, off by default
- **To-dos**: assignee, due date, priority, and a reminder before one falls due
- **Files**: attach to a trip, place, day entry or reservation. 50 MB each, 500 MB for video, with trash and restore
- **PDF export**: cover page, place photos, day notes, bookings and costs, with an optional page break per day
</td>
</tr>
@@ -104,64 +107,71 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### 👥 Collaboration
- **Real-time sync** — WebSocket. Changes appear instantly across all connected users
- **Multi-user trips** — invite members with role-based access
- **Invite links** — one-time or reusable links with expiry
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
- **2FA** — TOTP + backup codes
- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable
- **Collab suite** — group chat, shared notes, polls, day check-ins
- **Real-time sync** (WebSocket): edits land live for everyone who has that trip open
- **Members**: add by email or username, hand ownership to someone else, or add guests who have no login at all
- **Permissions**: an admin maps each of 16 trip actions to admin, trip owner, trip member or everybody
- **Invite links**: one reusable link per trip with an optional expiry. Admins can also issue signup invites with a use limit that drop the new account straight into a trip
- **Public share**: a read-only trip page anyone can open without an account
- **Collab**: group chat with replies, reactions and link previews, shared notes with attachments, polls, and a What's Next list of upcoming activities. Each of the four switches on its own
#### 📔 Journal, Atlas and Vacay
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads or a linked [Immich](https://immich.app) or Synology Photos library, map views, co-authors, and a public share link
- **Atlas**: mark countries and sub-national regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, plus a bucket list, travel stats and a consecutive-years-travelled count
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holiday overlays for 16 European countries, carry-over between years, and joint or read-only sharing of a plan
- **Collections** (off by default): a place library outside any trip, with labels, ratings, a custom image per place, copy-into-a-trip, and sharing by invite
</td>
<td width="50%" valign="top">
#### 📱 Mobile & PWA
#### 🧩 Plugins
- **Installable** — iOS and Android, straight from the browser, no App Store needed
- **Offline support** — Service Worker caches tiles, API, uploads via Workbox
- **Native feel** — fullscreen standalone, themed status bar, splash screen
- **Touch optimised** — mobile-specific layouts with safe-area handling
- **Third-party plugins**: install from the TREK registry or sideload a zip, switch on per instance, with their own pages under `/plugins/<id>`
- **Sandboxed**: one child process per plugin, 63 grantable permissions, an admin-edited outbound host allowlist, memory and RPC caps, and daily caps on AI and notification calls
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key. A sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
- **Extension points**: map markers and layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, calendar sources, route profiles and notification channels. Plugin pages run in an opaque-origin iframe
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) on npm, with a manifest validator, a mock host, and a dev-link mode that runs a local build against real data
#### 🤖 AI and MCP
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration. 199 tools, 30 resources, 4 prompts
- **Scopes**: 29 scopes in 14 groups, each one tickable on the consent screen. Tokens are bound to the `/mcp` resource, and every tool call lands in the audit log
- **Reach**: create trips and days, edit places, packing lists, to-dos, costs, reservations, collections and journeys, mark countries visited, all inside the scopes the token holds
- **Prompts**: `trip-summary`, plus `packing-list` and `budget-overview` when those addons are on
- **Addon-aware**: seven addon gates decide which tools and resources a session sees. Flipping one drops live sessions so the surface re-registers
- **Booking extraction** (off by default): read a confirmation with a local Ollama server, any OpenAI-compatible endpoint, or Anthropic, configured instance-wide or per user
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 🧩 Addons (admin-toggleable)
#### 📱 Mobile and offline
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency
- **Documents** — file attachments on trips, places, and reservations
- **Collab** — chat, notes, polls, day-by-day attendance
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations
- **MCP** — expose TREK to AI assistants via OAuth 2.1
- **Installable**: iOS and Android straight from the browser, no App Store. Runs standalone without browser chrome, with the status bar tinted per theme
- **Phone layouts**: a separate shell below 768px with its own tokens, a bottom bar and safe-area insets. Which items sit in that bar is up to the user
- **Offline reads**: the app shell and every route chunk are precached, and trips, places and file blobs live in a per-user IndexedDB store, so a trip opens with no network
- **Offline writes**: mutations queue and replay with an `X-Idempotency-Key`, so a reconnect cannot double-apply. A stale edit is parked for a keep-mine or keep-theirs choice
- **Offline maps**: pre-download a trip's raster tiles, and clear them again
- **API responses are never cached** by the service worker. They vary per session, so they always come from the network
</td>
<td width="50%" valign="top">
#### 🤖 AI / MCP
#### ⚙️ Admin, accounts and security
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
- **Granular scopes** — 27 OAuth scopes across 13 permission groups
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
</td>
</tr>
<tr>
<td colspan="2" valign="top">
#### ⚙️ Admin & customisation
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
- **Login methods**: password login, password registration, OIDC login, OIDC registration and passkey login each switch on and off on their own. `OIDC_ONLY` leaves SSO as the only way in
- **SSO (OIDC)**: one provider by discovery, with PKCE and `id_token` verification (Authentik, Keycloak, Google and the like)
- **2FA**: TOTP with ten single-use backup codes, and an admin can require it instance-wide
- **Passkeys**: WebAuthn login by fingerprint, face, PIN or security key, off until an admin enables it. A passkey also satisfies the 2FA requirement
- **Hardening**: per-IP limits on login, password reset and 2FA attempts, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
- **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, storage, audit log, and GitHub releases
- **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets
- **Storage**: pluggable storage backends per content category — keep everything on local disk, or add S3-compatible backends and replicate any category to them, configured entirely from the admin panel
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook and ntfy, plus any channel a plugin registers
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, compact density, reduce motion, and text size per tier
- **23 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca
- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
</td>
</tr>
@@ -171,6 +181,26 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<br />
<div align="center">
<a href="docs/screenshots/showcase-desktop-1.webp"><img src="docs/screenshots/showcase-desktop-1.webp" alt="Atlas · dashboard · trip planner" width="100%" /></a>
<a href="docs/screenshots/showcase-mobile.webp"><img src="docs/screenshots/showcase-mobile.webp" alt="Mobile PWA · dashboard, day plan, map, costs" width="100%" /></a>
<a href="docs/screenshots/showcase-desktop-2.webp"><img src="docs/screenshots/showcase-desktop-2.webp" alt="Collections · journey journal · costs" width="100%" /></a>
</div>
<br />
## AI usage
We use LLM-assisted coding tools across parts of this codebase. Nothing ships that a
maintainer has not read and understood: every change goes through a pull request, is
reviewed and tested, and has a human who can answer for it. "The AI wrote that" is not
an answer any of us would accept from ourselves.
See [**How we use AI in TREK**](https://github.com/liketrek/TREK/discussions/1851) for
the details.
<br />
## Get started in 30 seconds
```bash
@@ -183,7 +213,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
<div align="center">
&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#docker-compose-production">Docker Compose</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#helm-kubernetes">Helm / Kubernetes</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#install-as-app-pwa">Install as PWA</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#reverse-proxy">Reverse Proxy</a>&nbsp;&nbsp;·&nbsp;&nbsp;
<a href="#docker-compose-production">Docker Compose</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#helm-kubernetes">Helm / Kubernetes</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#install-as-app-pwa">Install as PWA</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#reverse-proxy">Reverse Proxy</a>
</div>
@@ -211,76 +241,29 @@ Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. A
<h2 id="docker-compose-production">Docker Compose (production)</h2>
<details>
<summary>Full compose example with secure defaults</summary>
```yaml
services:
app:
image: mauriceboe/trek:latest
container_name: trek
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- SETUID
- SETGID
tmpfs:
- /tmp:noexec,nosuid,size=64m
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # generate with: openssl rand -hex 32
- TZ=${TZ:-UTC}
- LOG_LEVEL=${LOG_LEVEL:-info}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
- APP_URL=${APP_URL:-} # required for OIDC + email links
# - FORCE_HTTPS=true # behind a TLS-terminating proxy
# - TRUST_PROXY=1
# - OIDC_ISSUER=https://auth.example.com
# - OIDC_CLIENT_ID=trek
# - OIDC_CLIENT_SECRET=supersecret
# - OIDC_DISPLAY_NAME=SSO
# - OIDC_ADMIN_CLAIM=groups
# - OIDC_ADMIN_VALUE=app-trek-admins
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
```
Then:
The repository ships a ready-to-use [`docker-compose.yml`](docker-compose.yml)
with secure defaults and every option documented inline. Download it, then:
```bash
docker compose up -d
```
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells the server how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
</details>
See [Install with Docker Compose](https://github.com/liketrek/TREK/wiki/Install-Docker-Compose)
for the full walkthrough.
<br />
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2>
```bash
helm repo add trek https://mauriceboe.github.io/TREK
helm repo add trek https://chart.liketrek.com
helm repo update
helm install trek trek/trek
```
See [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts/README.md) for values.
See [`charts/README.md`](https://github.com/liketrek/TREK/blob/main/charts/README.md) for values.
<br />
<h2 id="install-as-app-pwa">Install as App (PWA)</h2>
@@ -296,41 +279,17 @@ TREK then launches fullscreen with its own icon, just like a native app.
## Updating
**Docker Compose:**
See [Updating](https://github.com/liketrek/TREK/wiki/Updating) — Docker Compose,
Docker run, Helm, Portainer, Unraid and Proxmox, plus the encryption-key note.
```bash
docker compose pull && docker compose up -d
```
**Docker run** — reuse the original volume paths:
```bash
docker pull mauriceboe/trek
docker rm -f trek
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
```
> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
> [!IMPORTANT]
> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved.
<h3>Rotating the Encryption Key</h3>
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
```bash
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
```
The script creates a timestamped DB backup before making changes and prompts for old + new keys (input is not echoed).
<br />
<h2 id="reverse-proxy">Reverse Proxy</h2>
For production, put TREK behind a TLS-terminating reverse proxy. TREK uses WebSockets for real-time sync, so the proxy **must** support WebSocket upgrades on `/ws`.
If you use the MCP addon, the proxy must also pass the `Mcp-Session-Id` header through in both directions on `/mcp` — Nginx and Caddy do this by default, but a proxy that strips it makes every tool call open a new session instead of reusing one. See the [Reverse Proxy wiki page](https://github.com/liketrek/TREK/wiki/Reverse-Proxy) for details.
<details>
<summary>Nginx</summary>
@@ -368,6 +327,19 @@ server {
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
# Only needed if you use the MCP addon. Responses are Server-Sent Events,
# so buffering must be off or tool results arrive late.
location /mcp {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 3600s;
}
}
```
@@ -390,60 +362,18 @@ Caddy handles TLS and WebSockets automatically.
## Environment variables
<details>
<summary><b>Full reference</b></summary>
Every variable, its default and what it does: see
[Environment Variables](https://github.com/liketrek/TREK/wiki/Environment-Variables).
<br />
## Star History
| Variable | Description | Default |
|----------|-------------|---------|
| **Core** | | |
| `PORT` | Server port | `3000` |
| `NODE_ENV` | Environment (`production` / `development`) | `production` |
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `en` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
| `SESSION_DURATION` | How long a login session stays valid when **"Remember me" is unchecked** (the default): sets the `trek_session` JWT `exp` and issues a browser-session cookie (cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. | `24h` |
| `SESSION_DURATION_REMEMBER` | Session length when **"Remember me" is ticked** at login: a longer-lived JWT plus a persistent `trek_session` cookie that survives browser restarts. Same format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
| **OIDC / SSO** | | |
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
| `OIDC_CLIENT_ID` | OIDC client ID | — |
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
| `OIDC_ONLY` | Force SSO-only mode: disables password login + registration, regardless of Admin > Settings. The first SSO login becomes admin. | `false` |
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — |
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
| `OIDC_SCOPE` | Space-separated OIDC scopes. **Fully replaces** the default — always include `openid email profile`. | `openid email profile` |
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint (e.g. Authentik: `.../application/o/trek/.well-known/openid-configuration`) | — |
| **Initial setup** | | |
| `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` |
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
| **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
</details>
<br />
## Data & Backups
- **Database** — SQLite, stored in `./data/travel.db`
- **Uploads** — stored in `./uploads/`
- **Logs** — `./data/logs/trek.log` (auto-rotated)
- **Backups** — create and restore via Admin Panel
- **Auto-Backups** — configurable schedule and retention in Admin Panel
<br />
<a href="https://www.star-history.com/?repos=liketrek%2FTREK&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&theme=dark&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
</picture>
</a>
## Data sources
@@ -452,7 +382,8 @@ The Atlas map's country and sub-national (province/county) boundaries come from
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md)
for full third-party attributions.
<br />
## License
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
-25
View File
@@ -1,25 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
CLIENT_DIR="$REPO_ROOT/client"
SERVER_DIR="$REPO_ROOT/server"
PUBLIC_DIR="$REPO_ROOT/server/public"
echo "==> Installing client dependencies"
cd "$CLIENT_DIR"
npm ci
echo "==> Building client"
npm run build
echo "==> Installing server dependencies"
cd "$SERVER_DIR"
npm ci
echo "==> Populating server/public"
find "$PUBLIC_DIR" -mindepth 1 ! -name '.gitkeep' -delete
cp -r "$CLIENT_DIR/dist/." "$PUBLIC_DIR/"
cp -r "$CLIENT_DIR/public/fonts" "$PUBLIC_DIR/fonts"
echo "==> Done — server/public is ready"
+3 -3
View File
@@ -1,9 +1,9 @@
<?xml version="1.0"?>
<CommunityApplications>
<Profile>TREK is a self-hosted, real-time collaborative travel planner. Plan trips together with interactive maps, budgets, bookings, packing lists, day-by-day itineraries and file management — every change syncs instantly across everyone in your group. Includes OIDC/SSO, TOTP MFA, dark mode, PWA support, multi-language UI and a modular addon system (Vacay, Atlas, Collab, Budget, Packing, Journey). Maintained by mauriceboe — support and bug reports via GitHub Issues.</Profile>
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png</Icon>
<WebPage>https://github.com/mauriceboe/TREK</WebPage>
<Forum>https://github.com/mauriceboe/TREK/issues</Forum>
<Icon>https://raw.githubusercontent.com/liketrek/TREK/main/docs/trek-icon.png</Icon>
<WebPage>https://github.com/liketrek/TREK</WebPage>
<Forum>https://github.com/liketrek/TREK/issues</Forum>
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
<DonateText>Support TREK development</DonateText>
</CommunityApplications>
+5 -1
View File
@@ -15,11 +15,13 @@ This is a minimal Helm chart for deploying the TREK app.
A hosted Helm repository is available:
```sh
helm repo add trek https://mauriceboe.github.io/TREK
helm repo add trek https://chart.liketrek.com
helm repo update
helm install trek trek/trek
```
> **Note:** `chart.liketrek.com` is a custom domain (CNAME) for the GitHub Pages site at `https://liketrek.github.io/TREK` — both URLs serve the same repository. The github.io URL keeps working (it redirects to `chart.liketrek.com`), but the custom domain is the canonical one to use.
## Usage
Or install directly from the local chart:
@@ -40,6 +42,8 @@ See `values.yaml` for more options.
## Notes
- Ingress is off by default. Enable and configure hosts for your domain.
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class.
- To use your own PVCs, set `persistence.data.existingClaim` and/or `persistence.uploads.existingClaim`. The other values for that volume (size, storageClassName, annotations) are then ignored.
- With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only.
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.1.3
version: 4.1.0
description: Minimal Helm chart for TREK app
appVersion: "3.1.3"
appVersion: "4.1.0"
+6
View File
@@ -21,3 +21,9 @@
4. Only one method should be used at a time. If both `generateEncryptionKey` and `existingSecret` are
set, `existingSecret` takes precedence. Ensure the referenced secret and key exist in the namespace.
5. Persistence:
- To bind your own PVCs, set `persistence.data.existingClaim` and/or `persistence.uploads.existingClaim`.
The other values for that volume (size, storageClassName, annotations) are then ignored.
- With `persistence.enabled=false` the volumes use an emptyDir — storage is ephemeral and is lost
when the pod restarts. Use only for testing.
+3
View File
@@ -13,6 +13,9 @@ data:
{{- if .Values.env.LOG_LEVEL }}
LOG_LEVEL: {{ .Values.env.LOG_LEVEL | quote }}
{{- end }}
{{- if .Values.env.TREK_WIKI_DIR }}
TREK_WIKI_DIR: {{ .Values.env.TREK_WIKI_DIR | quote }}
{{- end }}
{{- if .Values.env.ALLOWED_ORIGINS }}
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
{{- end }}
+22 -2
View File
@@ -6,6 +6,12 @@ metadata:
app: {{ include "trek.name" . }}
spec:
replicas: 1
# TREK is a single-writer SQLite app on a ReadWriteOnce PVC, so the default
# RollingUpdate would start a second pod holding the same volume before the old one
# exits — a Multi-Attach deadlock, or two processes on one travel.db. Recreate tears
# the old pod down first. Override to RollingUpdate only with a ReadWriteMany volume.
strategy:
type: {{ .Values.updateStrategy | default "Recreate" }}
selector:
matchLabels:
app: {{ include "trek.name" . }}
@@ -63,6 +69,12 @@ spec:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: OIDC_CLIENT_SECRET
optional: true
- name: UNSPLASH_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: UNSPLASH_ACCESS_KEY
optional: true
volumeMounts:
- name: data
mountPath: /app/data
@@ -82,8 +94,16 @@ spec:
periodSeconds: 10
volumes:
- name: data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "trek.fullname" . }}-data
claimName: {{ default (printf "%s-data" (include "trek.fullname" .)) .Values.persistence.data.existingClaim }}
{{- else }}
emptyDir: {}
{{- end }}
- name: uploads
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "trek.fullname" . }}-uploads
claimName: {{ default (printf "%s-uploads" (include "trek.fullname" .)) .Values.persistence.uploads.existingClaim }}
{{- else }}
emptyDir: {}
{{- end }}
+3 -1
View File
@@ -1,4 +1,4 @@
{{- if .Values.persistence.enabled }}
{{- if and .Values.persistence.enabled (not .Values.persistence.data.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
@@ -18,7 +18,9 @@ spec:
resources:
requests:
storage: {{ .Values.persistence.data.size }}
{{- end }}
---
{{- if and .Values.persistence.enabled (not .Values.persistence.uploads.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
+6
View File
@@ -17,6 +17,9 @@ data:
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }}
{{- end }}
{{- end }}
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
@@ -44,4 +47,7 @@ stringData:
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }}
{{- end }}
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
{{- end }}
{{- end }}
+22
View File
@@ -4,6 +4,11 @@ image:
# tag: latest
pullPolicy: IfNotPresent
# Deployment update strategy. Recreate is the safe default for the single-writer SQLite
# DB on a ReadWriteOnce volume (the old pod is torn down before the new one starts).
# Set to RollingUpdate only if you back the data volume with ReadWriteMany storage.
updateStrategy: Recreate
# Optional image pull secrets for private registries
imagePullSecrets: []
# - name: my-registry-secret
@@ -19,6 +24,12 @@ env:
# Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin).
# LOG_LEVEL: "info"
# "info" = concise user actions, "debug" = verbose details.
# TREK_WIKI_DIR: "/app/wiki"
# Where the in-app Help pages (/help) read their content from. Leave unset: the
# image ships the wiki at /app/wiki and finds it automatically, so Help matches
# the version you are running. Only set this to serve your own docs from a mounted
# volume. If the path does not exist, Help falls back to fetching the public GitHub
# wiki, which needs egress and tracks the latest release rather than your version.
# DEFAULT_LANGUAGE: "en"
# Default language on the login page for users with no saved preference.
# Browser/OS language is auto-detected first; this is the fallback when no match is found.
@@ -92,6 +103,12 @@ secretEnv:
ADMIN_PASSWORD: ""
# OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID.
OIDC_CLIENT_SECRET: ""
# Optional Unsplash Access Key for trip-cover and place-image search.
# Without one, TREK uses Unsplash's unauthenticated endpoint, which some
# datacenter/VPS IPs (including many Kubernetes clusters) are blocked from.
# Get a free key at https://unsplash.com/developers. Can also be set per-admin
# in Admin > Settings; this value overrides that. Leave empty to disable.
UNSPLASH_ACCESS_KEY: ""
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
generateEncryptionKey: false
@@ -101,15 +118,20 @@ existingSecret: ""
existingSecretKey: ENCRYPTION_KEY
persistence:
# When disabled, volumes fall back to an ephemeral emptyDir (data lost on pod restart).
enabled: true
data:
size: 1Gi
# Leave empty to use the cluster's default StorageClass; set to bind a specific class.
storageClassName: ""
# Bind an existing PVC. The other values (size, storageClassName, annotations) are then ignored.
existingClaim: ""
annotations: {}
uploads:
size: 1Gi
storageClassName: ""
# Specify an existing PVC to bind. The other values are then ignored.
existingClaim: ""
annotations: {}
resources:
+3
View File
@@ -3,3 +3,6 @@ e2e/.tmp/
test-results/
playwright-report/
playwright/.cache/
# vite-plugin-pwa dev output (devOptions.enabled)
dev-dist/
+77
View File
@@ -0,0 +1,77 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and `server/CLAUDE.md` for the API. This file covers client internals only.
## Commands (run from `client/`)
```bash
npm run dev # Vite dev server; proxies /api,/ws,/uploads,/oauth → http://localhost:3001
npm run build # prebuild generates PWA icons, then vite build
npm run typecheck # tsc --noEmit
npm run lint # eslint .
npm run lint:pages # enforce the Page pattern (scripts/check-page-pattern.mjs)
npm run test # vitest run (tests/** + src/**/*.test.{ts,tsx})
npm run test:unit # tests/unit
npm run test:integration # tests/integration + co-located src tests
npm run test:coverage # v8 coverage
npm run e2e # Playwright
npm run e2e:report # open last Playwright report
npm run shots # Playwright screenshots project (shots:promote to accept)
npm run theme:lint # theme conformance audit (theme:lint:strict exits 1 on findings; not a CI gate)
```
Single test:
```bash
npx vitest run src/store/slices/budgetSlice.test.ts
npx vitest run -t "optimistically adds the place"
npx vitest watch src/pages/DashboardPage.test.tsx
```
The dev server needs the API on **:3001** — run the server (`npm run dev` from root or `server/`) alongside, or `npm run dev` at the repo root to start both.
## Page pattern (enforced — `lint:pages` will fail otherwise)
Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use<Page>()` hook** under `src/pages/<page>/`. The page body must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` — only context hooks like `useTranslation()` are allowed. Optional `<page>Model.ts` holds pure (React-free) types/helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep rendered JSX byte-identical — it's a refactor of where logic lives.
## Data flow — offline-first
The layering is **store → repo → api | Dexie**, and writes go through a mutation queue:
- **`src/store/`** — Zustand. `tripStore.ts` is composed from **slices** in `store/slices/` (places, days, packing, todo, budget, reservations, files, assignments, dayNotes). Other stores: auth, settings, addon, permissions, inAppNotification, journey, vacay, systemNotice, collection, saveToCollection, plugin, backgroundTasks. `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state.
- **`src/repo/`** — per-entity repositories that mediate between the network and the offline cache. The pattern: if `navigator.onLine`, call the REST API and upsert into Dexie; if offline, read from Dexie. Always go through a repo for trip data, not the API directly.
- **`src/api/client.ts`** — single Axios instance; request/response types imported from `@trek/shared` Zod schemas. `api/websocket.ts` manages the `/ws` connection.
- **`src/db/offlineDb.ts`** — Dexie/IndexedDB schema: cached entities + the `QueuedMutation` table + `SyncMeta` (per-trip last-sync, prefetched-tiles bbox) + a `BlobCache` for offline file/photo access.
- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (with a temporary **negative id** for creates), then on reconnect `flush()` replays REST with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the Dexie row. `tripSyncManager.ts` / `syncTriggers.ts` / `connectivity.ts` drive reconnection sync; `tilePrefetcher.ts` pre-downloads map tiles.
## Direction (from the 2026 audit)
The offline core is flagship work surrounded by a periphery that ignores it. New code must extend the core's discipline, not the periphery's shortcuts:
- **One data path, no layer skips**: component → feature hook → store/slice → `repo/` → `api/` | Dexie. **Never import `api/client` (or raw fetch/axios) from a component or modal** — especially never *write* from one. A long tail of existing files (~190 at last count) bypasses the layering and no lint ratchet holds the line yet; that's debt, not precedent. New API surface goes in a per-domain `api/<domain>.ts`, not the `client.ts` god module.
- **Offline-first is the product promise, not a trip-planner feature.** New domains should get a repo with both read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry), extending the existing pattern rather than hand-rolling a variant. If a domain is deliberately online-only, mark it so explicitly.
- **No God components or God hooks.** The page pattern is a floor, not a ceiling — a 900-line `use<Page>()` hook that satisfies the linter is still coupling. Decompose hooks by concern, split renders into memoizable sections, and don't pass 50-field prop bags with inline-arrow callbacks (it defeats `React.memo`).
- **Prefer React 19 idioms over hand-rolled machinery**: `useOptimistic` for optimistic updates, `useActionState`/`useFormStatus` for forms, `use()` + Suspense for read paths, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference implementation).
- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`; silently keeping state the server rejected is a data-integrity bug.
- **WS remote-event handling must match local slice reducers field-for-field.** If a slice reducer preserves embedded fields on update (e.g. `place_time`/`end_time`), the corresponding `remoteEventHandler` branch must apply the identical merge semantics — divergence is silent, cross-user data loss.
- **Subscribe with selectors** (`useShallow` for multi-field reads); never `const s = useStore()` whole-store; no store getters that allocate per call. Use `getState()` for imperative access in effects.
- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` directly in feature code.
- **Rendering security**: never interpolate user content into HTML strings (map tooltips/popups included — build via DOM + `textContent`); untrusted or cross-user markdown gets `rehype-sanitize`; never `rehype-raw` near those surfaces.
- **No `window` CustomEvent buses or global mutable `window` state** — use the store or a typed emitter. No `any` at boundaries: WS payloads and map renderer props get real types, validated where they enter.
- **Hygiene**: error boundaries around new shells/routes; every async `.then(setState)` needs a cancelled flag or `AbortController`; no `eslint-disable exhaustive-deps` as a routine tool (a missing memo dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops — batch; search for an existing utility/hook before writing a duplicate.
- **Theming**: use the semantic Tailwind tokens (`bg-surface*`, `text-content`, `border-edge`, `bg-accent`, status colors) — no raw palette classes, hex literals, or invented CSS vars; only `applyAppearance()` mutates `<html>` styling.
## Other big-picture pieces
- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet (`MapView.tsx`) and Mapbox GL (`MapViewGL.tsx`), chosen at runtime by `MapViewAuto.tsx` based on the tile-source setting. GL-specific overlay/marker logic lives in the `*Mapbox.ts` siblings. Keep both renderers in sync when changing map features. **Offline maps**: only Leaflet supports full pre-download (raster tiles via `sync/tilePrefetcher.ts`, cached by the Workbox `map-tiles` rule). Mapbox GL is best-effort offline — its vector tiles are cached opportunistically as you browse online (the `mapbox-tiles` SW rule), not prefetched. Tile requests stay `no-cors` so self-hosted/custom tile providers without CORS headers keep working; `navigator.storage.persist()` (called at app init) keeps cached tiles from being evicted under storage pressure.
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled synchronously as the fallback; every other locale is a **dynamic `import('@trek/shared/i18n/<locale>')`** so Vite code-splits one chunk per locale and only the active one is fetched. `t(key)` resolves top-level keys from `@trek/shared/i18n`. Add new strings to the locale files in **`shared/`**, not here. RTL handled via `isRtlLanguage`.
- **Mobile shell** (`src/mobile/`): below 768px (`useIsPhone`, reactive via matchMedia) `App.tsx` wraps routes in `MobileShell`, and phone-specific screens under `mobile/screens/<domain>/` (trip, dashboard, atlas, vacay, settings, admin, …) take over; the desktop/tablet experience at ≥768px is untouched. UI changes to a domain that has an `M*` screen usually need both the desktop component and the mobile screen updated.
- **Plugins UI** (`src/components/Plugins/`): third-party plugin surfaces render in a sandboxed iframe — `PluginFrame.tsx` owns the postMessage bridge and hands the plugin the current theme's design tokens (live re-sync on theme/accent change). Contribution points: `PluginWidgets.tsx` (dashboard), `PluginDaySchedule.tsx` (itinerary rows), `Map/MapPluginLayers.tsx`/`MapPluginMarkers.tsx` (map). `store/pluginStore.ts` holds installed-plugin state; admin install/manage in `components/Admin/AdminPluginsPanel.tsx`. The server-side runtime is `server/src/nest/plugins/`; plugin authoring is covered by the `trek-plugin-dev` skill.
- **PWA** (`vite.config.js`): `VitePWA` with `registerType: 'autoUpdate'` and Workbox `runtimeCaching` for tiles/API/uploads. `prebuild` (`scripts/generate-icons.mjs`) generates app icons before build.
- **Native shell** (`client/src/platform/`): the Capacitor apps are not served from the same origin as the API, so `getServerOrigin()` is the single source of truth for where the server lives — `''` in the web build (relative URLs stay relative), the user's chosen server in native. Requests go through `apiFetch` / the `apiClient` interceptor, assets through `resolveAssetUrl` / `<ServerImage>`; ESLint blocks bare `fetch('/api…')` and `/uploads` literals so the seam cannot reopen. Native authenticates with `Authorization: Bearer` because the session cookie is `sameSite: 'lax'` and a webview can never send it cross-site. Saved servers are "profiles" (`serverProfiles.ts`, Capacitor Preferences) with tokens in device secure storage (`serverTokens.ts`) and a per-profile Dexie namespace. **The web build must stay byte-identical — especially Dexie names**; see `offlineDb.naming.test.ts`.
## Tests
vitest with `@vitejs/plugin-react`, jsdom (custom `tests/environment/jsdom-native-abort.ts`), `forks` pool. Tests live both in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie in tests. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`). Playwright e2e config separate (`npm run e2e`).
+7 -10
View File
@@ -1,4 +1,5 @@
import { test as setup, expect } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Relative to the config dir (client/), matching `storageState` in
// playwright.config.ts. Playwright runs from the client workspace root.
@@ -27,16 +28,12 @@ setup('authenticate the seeded admin (incl. forced password change)', async ({ p
await page.waitForURL('**/dashboard', { timeout: 30_000 })
// Dismiss the first-run "Welcome to TREK" system-notice modal(s). It renders
// asynchronously (after the notices fetch), so wait for it before clicking.
// Dismissal is recorded server-side against this user, so clearing it here
// keeps it cleared for every authenticated flow in the run (shared test DB).
const ok = page.getByRole('button', { name: 'OK', exact: true })
await ok.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {})
for (let i = 0; i < 8 && (await ok.isVisible().catch(() => false)); i++) {
await ok.click()
await page.waitForTimeout(400)
}
// Dismiss the first-run system-notice modal(s) — currently the thank-you /
// support modal, which has NO "OK" button (only CTAs + the X). The shared
// helper handles both notice shapes; dismissal is recorded server-side
// against this user, so clearing it here keeps it cleared for every
// authenticated flow in the run (shared test DB).
await dismissSystemNotices(page, 10_000)
await page.context().storageState({ path: stateFile })
})
+12 -6
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Trip lifecycle (core): from the dashboard, open the new-trip modal, name the
// trip, submit, and confirm it shows up on the dashboard. Exercises the whole
@@ -7,18 +8,23 @@ import { test, expect } from '@playwright/test'
test('create a trip and see it on the dashboard', async ({ page }) => {
await page.goto('/dashboard')
// The release notice greets a freshly seeded user and its backdrop eats the click below.
await dismissSystemNotices(page)
// The "+ New Trip" card is always rendered in the default (planned) filter.
await page.locator('.add-trip-card').click()
// Scope to the shared Modal (.modal-backdrop). Its form has no in-form submit
// button (the primary action lives in the footer), so click it explicitly
// rather than pressing Enter. The Create button is the slate primary button;
// Cancel is the bordered one.
const modal = page.locator('.modal-backdrop')
// Scope to the shared Modal (.trek-modal-backdrop — namespaced so content blockers
// don't hide a generic .modal-backdrop). Its form has no in-form submit button (the
// primary action lives in the footer), so click it explicitly rather than pressing
// Enter. The Create button is the slate primary button; Cancel is the bordered one.
const modal = page.locator('.trek-modal-backdrop')
await expect(modal).toBeVisible()
// Target Title by placeholder: the cover-image search inputs sit above it, so
// input[type=text].first() is the photo search box, not the field we want.
const title = `E2E Trip ${Date.now()}`
await modal.locator('input[type="text"]').first().fill(title)
await modal.getByPlaceholder('e.g. Summer in Japan').fill(title)
await modal.getByRole('button', { name: 'Create New Trip' }).click()
await expect(page.getByText(title).first()).toBeVisible({ timeout: 15_000 })
+43
View File
@@ -0,0 +1,43 @@
import type { Page } from '@playwright/test'
/**
* Dismiss the system-notice modal(s) (SystemNoticeHost), which greet a freshly
* seeded user on first load and cover the dashboard — the backdrop swallows
* clicks aimed at anything underneath, `.add-trip-card` included.
*
* The host renders asynchronously (after the notices fetch), so wait for the
* notice dialog before deciding there is nothing to clear. Every lookup is
* scoped INSIDE the dialog — an unscoped /next/i can match dashboard buttons
* (carousel arrows) and satisfy the wait before the modal even mounts.
*
* A notice closes one of two ways depending on its shape:
* - CTA-bearing notices (e.g. the thank-you/support modal) only offer the
* X button (`aria-label="Dismiss"`), shown on the last page.
* - CTA-less notices show an "OK" button that pages forward and dismisses on
* the last page.
* Multi-page notices are paged through via the pager's Next button first.
* Dismissal is persisted server-side per user, so clearing once keeps it
* cleared for every later spec in the run (shared test DB).
*/
export async function dismissSystemNotices(page: Page, appearTimeoutMs = 3_000): Promise<void> {
const dialog = page.getByRole('dialog').first()
await dialog.waitFor({ state: 'visible', timeout: appearTimeoutMs }).catch(() => {})
// Clear up to a handful of queued notices.
for (let notice = 0; notice < 4 && (await dialog.isVisible().catch(() => false)); notice++) {
const next = dialog.getByRole('button', { name: /next/i })
for (let i = 0; i < 8 && (await next.isVisible().catch(() => false)); i++) {
if (!(await next.isEnabled().catch(() => false))) break
await next.click()
}
const dismiss = dialog.getByRole('button', { name: 'Dismiss', exact: true })
const ok = dialog.getByRole('button', { name: 'OK', exact: true })
if (await dismiss.isVisible().catch(() => false)) await dismiss.click()
else if (await ok.isVisible().catch(() => false)) await ok.click()
else break
// Exit animation + the next queued notice mounting.
await page.waitForTimeout(400)
}
await dialog.waitFor({ state: 'detached', timeout: 5_000 }).catch(() => {})
}
+79
View File
@@ -0,0 +1,79 @@
import { test, expect, devices } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Tablet regression guard for #1432 — the places list must scroll under a touch swipe.
//
// A tablet is a coarse-pointer device at a *desktop* viewport width, so the width-based
// "is this mobile" check that 3.2.1 shipped left `draggable` armed on iPad: the swipe
// became an HTML5 drag and raised the drop-to-import overlay instead of scrolling. Drag
// is now gated on `(pointer: coarse)` (useIsTouch), and only a real device context proves
// it — a jsdom unit test cannot express "coarse pointer at 834px".
//
// Needs WebKit (`npx playwright install webkit`, plus libmanette-0.2-0 and libwoff1 on
// Debian/Ubuntu). WebKit is the right engine here, not a nicety: every browser on iPadOS
// is WebKit underneath, which is why the reporter saw this in all three they tried.
test.use({ ...devices['iPad Pro 11'] })
test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) => {
await page.goto('/dashboard')
await dismissSystemNotices(page)
await page.locator('.add-trip-card').click()
const createBtn = page.getByRole('button', { name: 'Create New Trip' })
await expect(createBtn).toBeVisible()
const title = `iPad 1432 ${Date.now()}`
await page.getByPlaceholder('e.g. Summer in Japan').fill(title)
await createBtn.click()
await page.getByText(title).first().click()
await expect(page).toHaveURL(/\/trips\/\d+/)
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
const tripId = page.url().match(/\/trips\/(\d+)/)![1]
// Seed enough places for the list to overflow and actually need scrolling.
for (let i = 1; i <= 25; i++) {
const res = await page.request.post(`/api/trips/${tripId}/places`, {
data: { name: `Place ${i}`, lat: 48.85 + i * 0.01, lng: 2.35 + i * 0.01 },
})
expect(res.ok(), `seed place ${i}`).toBeTruthy()
}
await page.reload()
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
await expect(page.getByText('Place 1').first()).toBeVisible({ timeout: 20_000 })
// The context must really be the one from the bug report: coarse pointer, desktop
// width. If either is wrong, everything below proves nothing.
const env = await page.evaluate(() => ({
coarse: window.matchMedia('(pointer: coarse)').matches,
width: window.innerWidth,
}))
expect(env.coarse, 'iPad reports a coarse primary pointer').toBe(true)
expect(env.width, 'iPad sits above the 768px "mobile" breakpoint').toBeGreaterThanOrEqual(768)
// 1. Rows must not be draggable — a draggable row is what swallowed the scroll gesture.
const row = page.locator('div[draggable]').filter({ hasText: 'Place 1' }).first()
await expect(row).toHaveAttribute('draggable', 'false')
// 2. The list must scroll, and no drop-to-import overlay may appear.
const scroller = page.locator('div[draggable]').first().locator('xpath=ancestor::div[@class="trek-stagger"]')
const before = await scroller.evaluate(el => el.scrollTop)
const box = (await scroller.boundingBox())!
await page.touchscreen.tap(box.x + box.width / 2, box.y + 40)
await scroller.evaluate(el => el.scrollBy(0, 200))
const after = await scroller.evaluate(el => el.scrollTop)
expect(after, 'places list scrolled').toBeGreaterThan(before)
await expect(page.getByText('Drop to import')).toHaveCount(0)
// 3. Drag being off means the arrow buttons are the only reorder affordance left —
// they must be visible (they were opacity:0 above 767px).
const arrowOpacity = await page.evaluate(() => {
const el = document.querySelector('.reorder-buttons')
return el ? getComputedStyle(el).opacity : 'absent'
})
expect(['1', 'absent']).toContain(arrowOpacity)
// 4. The iPad must still get the desktop two-pane layout — isMobile stayed width-based.
await expect(page.locator('.leaflet-container')).toBeVisible()
})
+98
View File
@@ -0,0 +1,98 @@
import { test, expect, devices } from '@playwright/test'
import type { Page } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Phone coverage for #2051: the day details area steps days under a horizontal
// swipe, so the trip is navigable one-handed instead of only from the chip rail
// pinned to the top of the screen.
//
// jsdom can drive the handlers but not the browser around them: whether the
// gesture survives real touch dispatch, whether the browser hands the vertical
// axis to its own scroller first, and whether a swipe at the first day stays put
// are all engine behaviour. This drives CDP's Input.dispatchTouchEvent, so the
// events are the ones a finger produces — not synthesised TouchEvent objects.
//
// Chromium, because CDP is where real touch injection lives. The file-level
// device sets a phone viewport with a coarse pointer, which is what puts the
// mobile shell on screen at all.
test.use({ ...devices['Pixel 7'] })
/** One finger, dispatched through the browser's own input pipeline. */
async function swipe(page: Page, from: { x: number; y: number }, to: { x: number; y: number }, steps = 8) {
const cdp = await page.context().newCDPSession(page)
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: from.x, y: from.y }] })
for (let i = 1; i <= steps; i++) {
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{
x: from.x + ((to.x - from.x) * i) / steps,
y: from.y + ((to.y - from.y) * i) / steps,
}],
})
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await cdp.detach()
// The out leg is 150ms and the in leg 220ms.
await page.waitForTimeout(500)
}
const activeChip = (page: Page) => page.locator('button[aria-current="true"]')
test('#2051 phone: the day details area swipes between days', async ({ page }) => {
await page.goto('/dashboard')
await dismissSystemNotices(page)
// Without a phone viewport and a coarse pointer the mobile shell never mounts,
// and everything below would be proving nothing.
const env = await page.evaluate(() => ({
width: window.innerWidth,
coarse: window.matchMedia('(pointer: coarse)').matches,
}))
expect(env.width, 'Pixel sits below the 768px phone breakpoint').toBeLessThan(768)
expect(env.coarse, 'Pixel reports a coarse primary pointer').toBe(true)
const stamp = Date.now()
const created = await page.request.post('/api/trips', {
data: { title: `Day swipe ${stamp}`, start_date: '2099-06-01', end_date: '2099-06-05' },
})
expect(created.ok(), 'seed trip').toBeTruthy()
const tripId = (await created.json()).trip?.id ?? (await created.json()).id
await page.goto(`/trips/${tripId}`)
await expect(activeChip(page)).toBeVisible({ timeout: 30_000 })
const chips = page.locator('button[aria-current], button[aria-current="true"]')
const rail = activeChip(page).locator('xpath=..')
expect(await rail.locator('button').count(), 'the seeded trip has five days').toBe(5)
expect(await chips.count()).toBeGreaterThan(0)
// The swipe zone: below the chip rail, above the dock, across the middle.
const mid = Math.round(env.width / 2)
const y = 460
const day1 = (await activeChip(page).textContent())!.trim()
// 1. A swipe left steps forward.
await swipe(page, { x: mid + 90, y }, { x: mid - 90, y })
const day2 = (await activeChip(page).textContent())!.trim()
expect(day2, 'swiping left moved to the next day').not.toBe(day1)
// 2. A swipe right steps back to where it started.
await swipe(page, { x: mid - 90, y }, { x: mid + 90, y })
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
// 3. The first day has nowhere to go back to — it must stay put, and the
// browser must not have been handed a page-back either.
await swipe(page, { x: mid - 90, y }, { x: mid + 90, y })
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
expect(page.url()).toContain(`/trips/${tripId}`)
// 4. A vertical drag belongs to the card's own scroller, never to the day.
await swipe(page, { x: mid, y: y + 120 }, { x: mid, y: y - 120 })
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
// 5. And the rail itself still works, so the gesture added a path rather than
// replacing one.
await rail.locator('button').nth(2).click()
await expect(activeChip(page)).not.toHaveText(day1)
})
+61
View File
@@ -0,0 +1,61 @@
import { test, expect } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// The day-plan reorder arrows are hover-revealed on desktop. The rule that did that was
// dead for a long time — it targeted `.place-row .reorder-btns`, neither of which exists
// (the component renders `.reorder-buttons` inside an unclassed row), so the buttons sat
// at opacity:0 with no way to reveal them.
//
// That is not merely "invisible": opacity:0 still hit-tests, so every itinerary row and
// note carried an invisible, fully clickable target that silently reordered the trip.
// These cases pin both halves — hidden means non-interactive, hover means visible.
test('desktop: reorder arrows are hidden-and-inert until the row is hovered', async ({ page }) => {
await page.goto('/dashboard')
await dismissSystemNotices(page)
await page.locator('.add-trip-card').click()
const modal = page.locator('.trek-modal-backdrop')
await expect(modal).toBeVisible()
const title = `Reorder ${Date.now()}`
await modal.getByPlaceholder('e.g. Summer in Japan').fill(title)
await modal.getByRole('button', { name: 'Create New Trip' }).click()
await page.getByText(title).first().click()
await expect(page).toHaveURL(/\/trips\/\d+/)
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
// Two places on day 1, so the day plan renders rows carrying reorder arrows.
const tripId = page.url().match(/\/trips\/(\d+)/)![1]
const daysRes = await (await page.request.get(`/api/trips/${tripId}/days`)).json()
const dayId = (daysRes.days ?? daysRes)[0].id
for (const name of ['Alpha', 'Beta']) {
const res = await page.request.post(`/api/trips/${tripId}/places`, {
data: { name, lat: 48.85, lng: 2.35 },
})
const body = await res.json()
await page.request.post(`/api/trips/${tripId}/days/${dayId}/assignments`, {
data: { place_id: body.place?.id ?? body.id },
})
}
await page.reload()
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
const row = page.locator('.dp-row').filter({ hasText: 'Alpha' }).first()
await expect(row).toBeVisible({ timeout: 20_000 })
const arrows = row.locator('.reorder-buttons')
// Unhovered: invisible AND inert — a click there must not land on the button.
const idle = await arrows.evaluate(el => {
const cs = getComputedStyle(el)
const r = el.getBoundingClientRect()
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)
return { opacity: cs.opacity, hitsArrow: !!hit?.closest('.reorder-buttons') }
})
expect(idle.opacity, 'arrows hidden until hover').toBe('0')
expect(idle.hitsArrow, 'hidden arrows must not swallow clicks').toBe(false)
// Hovered: revealed and clickable.
await row.hover()
await expect(arrows).toHaveCSS('opacity', '1')
await expect(arrows).toHaveCSS('pointer-events', 'auto')
})
+26
View File
@@ -0,0 +1,26 @@
import { test, expect } from './shot'
/**
* Unauthenticated surfaces. `storageState: undefined` drops the admin session
* this project otherwise inherits, so these render as a logged-out visitor sees
* them — which is the entire point of the login and registration pages.
*/
test.use({ storageState: undefined })
test('login page', async ({ page, shot }) => {
await page.goto('/login')
await expect(page.locator('input[type="email"]')).toBeVisible()
await shot.page_('Login')
})
test('registration page', async ({ page, shot }) => {
await page.goto('/register')
await page.waitForTimeout(500)
await shot.page_('Registration')
})
test('forgot password', async ({ page, shot }) => {
await page.goto('/forgot-password')
await page.waitForTimeout(500)
await shot.page_('PasswordReset')
})
+69
View File
@@ -0,0 +1,69 @@
import { test, clearNotices, expect } from './shot'
import type { Page, Locator } from '@playwright/test'
import { readFileSync } from 'node:fs'
import path from 'node:path'
/**
* Collab surfaces, one capture each.
*
* Until now a single Collab.png illustrated four different wiki pages — chat,
* notes, polls and the What's Next widget — so at most one of them showed the
* feature its page described.
*
* The Collab view is NOT tabbed: CollabPanel renders chat in a fixed 380px left
* column and the other panels beside it, all visible at once (CollabPanel.tsx:94).
* So each capture targets its own card element rather than clicking a tab.
*/
const seed = JSON.parse(
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
) as { tripId: number }
/**
* The panel card containing a given piece of seeded content — see cardClass in
* CollabPanel.tsx:20.
*
* Matching on content rather than the panel heading is deliberate: the headings
* render uppercase through CSS while the DOM text is "Notes" / "Polls", and
* those same words also appear in the mobile tab bar, so a heading match is both
* wrong-cased and ambiguous.
*/
function card(page: Page, contains: string): Locator {
return page
.locator('div.bg-surface-card.rounded-2xl')
.filter({ hasText: contains })
.last()
}
test.beforeEach(async ({ page }) => {
await page.goto(`/trips/${seed.tripId}`)
await clearNotices(page)
await page.getByRole('button', { name: 'Collab', exact: true }).first().click()
await page.waitForTimeout(1200)
})
test('collab chat', async ({ page, shot }) => {
// Seeded as three different people; a single-voice log would misrepresent it.
// The chat auto-scrolls to the newest message, so assert on the last line of
// the seeded conversation rather than the first — the first is off-screen.
await expect(page.getByText('kaiseki', { exact: false }).first()).toBeVisible()
await shot.element('CollabChat', card(page, 'kaiseki'))
})
test('collab notes', async ({ page, shot }) => {
await expect(page.getByText('Rail passes', { exact: false })).toBeVisible()
await shot.element('CollabNotes', card(page, 'Rail passes'))
})
test('collab polls', async ({ page, shot }) => {
await expect(page.getByText('free for Nara', { exact: false })).toBeVisible()
await shot.element('CollabPolls', card(page, 'free for Nara'))
})
test("what's next widget", async ({ page, shot }) => {
await shot.element('WhatsNext', card(page, "What's Next"))
})
test('collab overview', async ({ page, shot }) => {
await shot.page_('Collab')
})
+88
View File
@@ -0,0 +1,88 @@
import { test, clearNotices, expect } from './shot'
import { readFileSync } from 'node:fs'
import path from 'node:path'
/**
* Detail pages and the surfaces that need a couple of clicks to reach.
*
* Each capture asserts something specific to the surface before shooting, so a
* navigation that quietly lands on a fallback (or an addon that is off) fails
* the run instead of producing a screenshot of the wrong screen.
*/
const seed = JSON.parse(
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
) as { tripId: number; collectionId?: number; journeyId?: number }
test('collection detail', async ({ page, shot }) => {
test.skip(!seed.collectionId, 'collections addon unavailable during seed')
await page.goto(`/collections/${seed.collectionId}`)
await clearNotices(page)
await shot.page_('CollectionDetail')
})
test('journey detail', async ({ page, shot }) => {
test.skip(!seed.journeyId, 'journey addon unavailable during seed')
await page.goto(`/journey/${seed.journeyId}`)
await clearNotices(page)
await shot.page_('JourneyDetail')
})
test('mcp access — admin', async ({ page, shot }) => {
await page.goto('/admin')
await clearNotices(page)
await page.getByRole('button', { name: 'MCP Access', exact: true }).first().click()
await page.waitForTimeout(700)
await shot.page_('MCPAccess')
})
test('two-factor setup', async ({ page, shot }) => {
await page.goto('/settings')
await clearNotices(page)
await page.getByRole('button', { name: 'Account', exact: true }).first().click()
await page.waitForTimeout(600)
// The enrolment flow is behind a button whose label varies with state; match
// loosely and fall back to capturing the tab itself.
const enable = page.getByRole('button', { name: /two-factor|2fa|authenticator/i }).first()
if (await enable.isVisible().catch(() => false)) {
await enable.click()
await page.waitForTimeout(900)
}
await shot.page_('2FA')
})
/**
* Settle-up.
*
* WARNING for anyone extending this file: the "Settle up" button in the Costs
* toolbar is not a view — it RECORDS the settling transfers. An earlier version
* of this test clicked it, which zeroed every balance and left the capture
* showing "Everyone's square". Because all screenshot specs share one database
* and this file sorts before planner.shot.ts, it also poisoned Costs.png in the
* same run.
*
* Screenshot specs must not mutate state. Capture the "Add payment" dialog
* instead — same surface, no side effect — and close it again.
*/
test('costs — record a settle-up payment', async ({ page, shot }) => {
await page.goto(`/trips/${seed.tripId}`)
await clearNotices(page)
await page.getByRole('button', { name: 'Costs', exact: true }).first().click()
await page.waitForTimeout(800)
const addPayment = page.getByRole('button', { name: /add payment/i }).first()
test.skip(!(await addPayment.isVisible().catch(() => false)), 'no add-payment entry point rendered')
await addPayment.click()
await page.waitForTimeout(700)
const modal = page.locator('.trek-modal-backdrop > div').first()
await expect(modal).toBeVisible()
await shot.element('CostsSettleUp', modal)
})
test('trip files', async ({ page, shot }) => {
await page.goto(`/trips/${seed.tripId}/files`)
await clearNotices(page)
await expect(page).toHaveURL(/files/)
await shot.page_('Documents')
})
+42
View File
@@ -0,0 +1,42 @@
import { test, clearNotices, expect } from './shot'
import { readFileSync } from 'node:fs'
import path from 'node:path'
/**
* Modals and dialogs.
*
* Captured as element screenshots (not full page) so the wiki gets the dialog
* itself rather than a dimmed backdrop with a small box in the middle. Each one
* asserts the dialog is actually open first — a missed click would otherwise
* silently produce a screenshot of the page behind it.
*/
const seed = JSON.parse(
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
) as { tripId: number }
/**
* The shared Modal (client/src/components/shared/Modal.tsx) sets neither
* role="dialog" nor aria-modal, so there is no accessible role to query — the
* backdrop class is the only stable hook. Target its child, which is the panel
* itself, so the capture excludes the dimmed backdrop.
*/
function dialog(page: import('@playwright/test').Page) {
return page.locator('.trek-modal-backdrop > div').first()
}
test('create trip modal — with the new currency field', async ({ page, shot }) => {
await page.goto('/dashboard')
await clearNotices(page)
await page.getByRole('button', { name: /new trip/i }).first().click()
await expect(dialog(page)).toBeVisible()
await shot.element('TripCreate', dialog(page))
})
test('share dialog', async ({ page, shot }) => {
await page.goto(`/trips/${seed.tripId}`)
await clearNotices(page)
await page.getByRole('button', { name: /share/i }).first().click()
await expect(dialog(page)).toBeVisible()
await shot.element('Share', dialog(page))
})
+67
View File
@@ -0,0 +1,67 @@
import { test, clearNotices } from './shot'
import { readFileSync } from 'node:fs'
import path from 'node:path'
/**
* Top-level navigable surfaces. One capture per route; anything that needs a
* dialog opened or a tab clicked lives in its own spec so a failure there
* cannot take these down with it.
*
* Names are the target filenames in wiki/assets/ — see docs/screenshot-map.md
* for which wiki page consumes which file.
*/
const seed = JSON.parse(
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
) as { tripId: number; collectionId?: number; journeyId?: number }
test.beforeEach(async ({ page }) => {
await page.goto('/dashboard')
await clearNotices(page)
})
test('dashboard', async ({ page, shot }) => {
await page.goto('/dashboard')
await clearNotices(page)
await shot.page_('DashboardWidgets')
})
test('trip planner', async ({ page, shot }) => {
await page.goto(`/trips/${seed.tripId}`)
await shot.page_('TripPlanner')
})
test('atlas', async ({ page, shot }) => {
await page.goto('/atlas')
await shot.page_('Atlas')
})
test('vacay', async ({ page, shot }) => {
await page.goto('/vacay')
await shot.page_('Vacay')
})
test('collections', async ({ page, shot }) => {
await page.goto('/collections')
await shot.page_('Collections')
})
test('journey', async ({ page, shot }) => {
await page.goto('/journey')
await shot.page_('Journey')
})
test('notifications inbox', async ({ page, shot }) => {
await page.goto('/notifications')
await shot.page_('NotificationsInbox')
})
test('in-app help', async ({ page, shot }) => {
await page.goto('/help')
await shot.page_('HelpInApp')
})
test('files', async ({ page, shot }) => {
await page.goto(`/trips/${seed.tripId}/files`)
await shot.page_('Files')
})
+47
View File
@@ -0,0 +1,47 @@
import { test, clearNotices } from './shot'
import { readFileSync } from 'node:fs'
import path from 'node:path'
/**
* Trip-planner tabs and dialogs.
*
* Tabs are reached by their visible label rather than a test id, deliberately:
* if a label is renamed (as Budget → Costs was in 3.3.0) this run fails loudly
* instead of silently capturing the wrong panel — which is exactly how the
* current wiki ended up with screenshots the text contradicts.
*/
const seed = JSON.parse(
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
) as { tripId: number }
test.beforeEach(async ({ page }) => {
await page.goto(`/trips/${seed.tripId}`)
await clearNotices(page)
})
async function openTab(page: import('@playwright/test').Page, label: string) {
await page.getByRole('button', { name: label, exact: true }).first().click()
await page.waitForTimeout(700)
}
test('costs panel', async ({ page, shot }) => {
await openTab(page, 'Costs')
await shot.page_('Costs')
})
test('lists — packing', async ({ page, shot }) => {
await openTab(page, 'Lists')
await shot.page_('PackingList')
})
test('transports', async ({ page, shot }) => {
await openTab(page, 'Transports')
await shot.page_('Transports')
})
test('bookings', async ({ page, shot }) => {
await openTab(page, 'Book')
await shot.page_('Bookings')
})
+59
View File
@@ -0,0 +1,59 @@
// Moves captured screenshots from the staging directory into wiki/assets/,
// downscaling and re-encoding on the way.
//
// Captures are taken at 1440px CSS width with deviceScaleFactor 2, i.e. 2880px
// of raw pixels. The wiki renders images at roughly 800–1000px, so shipping
// 2880px costs ~10x the bytes for detail nobody sees — that is how the existing
// assets reached 26 MB (one GIF alone is 9.1 MB). 1600px keeps the image sharp
// on HiDPI displays at the size it is actually shown.
//
// Usage: node e2e/screenshots/promote.mjs [--dry]
import sharp from 'sharp'
import { readdirSync, mkdirSync, statSync } from 'node:fs'
import path from 'node:path'
const SRC = path.join(process.cwd(), 'e2e', '.tmp', 'shots')
const DEST = path.join(process.cwd(), '..', 'wiki', 'assets')
const MAX_WIDTH = 1600
const dry = process.argv.includes('--dry')
mkdirSync(DEST, { recursive: true })
const files = readdirSync(SRC).filter(f => f.endsWith('.png'))
if (!files.length) {
console.error(`No screenshots in ${SRC} — run \`npm run shots\` first.`)
process.exit(1)
}
let before = 0
let after = 0
for (const file of files.sort()) {
const src = path.join(SRC, file)
const dest = path.join(DEST, file)
const srcBytes = statSync(src).size
before += srcBytes
const img = sharp(src)
const { width } = await img.metadata()
const pipeline = sharp(src)
.resize({ width: Math.min(width ?? MAX_WIDTH, MAX_WIDTH), withoutEnlargement: true })
.png({ compressionLevel: 9, effort: 10 })
const buf = await pipeline.toBuffer()
after += buf.length
const pct = Math.round((1 - buf.length / srcBytes) * 100)
console.log(
`${dry ? '[dry] ' : ''}${file.padEnd(28)} ${kb(srcBytes).padStart(8)} → ${kb(buf.length).padStart(8)} (-${pct}%)`,
)
if (!dry) await sharp(buf).toFile(dest)
}
console.log(`\n${files.length} files: ${kb(before)} → ${kb(after)} (-${Math.round((1 - after / before) * 100)}%)`)
if (dry) console.log('Dry run — nothing written. Drop --dry to promote into wiki/assets/.')
function kb(bytes) {
return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`
}
+37
View File
@@ -0,0 +1,37 @@
import { test as setup, expect } from '@playwright/test'
import { writeFileSync, mkdirSync } from 'node:fs'
import path from 'node:path'
import { seedDemoData } from './seed'
/**
* Populates the throwaway E2E database with the demo trip before any screenshot
* runs. Its own Playwright project so it executes exactly once, after `setup`
* (which produces the authenticated storageState) and before `screenshots`.
*
* The resulting ids are written to disk because Playwright projects do not
* share memory — the capture specs read them back.
*/
setup('seed the demo trip', async ({ page, playwright }) => {
// page.request carries the storageState cookie, so this is authenticated as
// the admin. The factory hands the seeder throwaway contexts for the other
// members — see the comment in seed.ts on why they must not share one.
const result = await seedDemoData(page.request, token =>
playwright.request.newContext({
baseURL: 'http://localhost:5173',
// MUST be explicit: newContext otherwise picks up the project's
// storageState, i.e. the admin's trek_session cookie — and the server
// reads the cookie BEFORE the Authorization header
// (server/src/middleware/auth.ts:9), so every "member" write would be
// recorded as the admin while still returning 200.
storageState: undefined,
extraHTTPHeaders: token ? { Authorization: `Bearer ${token}` } : {},
}),
)
expect(result.tripId, 'trip was created').toBeTruthy()
expect(result.placeIds.length, 'places were created').toBeGreaterThan(0)
const dir = path.join(process.cwd(), 'e2e', '.tmp')
mkdirSync(dir, { recursive: true })
writeFileSync(path.join(dir, 'seed.json'), JSON.stringify(result, null, 2))
})
+347
View File
@@ -0,0 +1,347 @@
import path from 'node:path'
import type { APIRequestContext } from '@playwright/test'
/**
* Demo data for the documentation screenshots.
*
* Seeded over the REST API (not the DB) so it exercises the same paths a real
* user would and stays honest about validation. The session cookie comes from
* the storageState that auth.setup.ts writes, so `page.request` is already
* authenticated as the seeded admin.
*
* Design notes that matter for the screenshots:
* - The trip is in **JPY**, deliberately. A EUR trip hides the entire v3.4.0
* currency rework (per-trip currency, frozen FX rates, foreign-currency
* settle-up) — the reader would see nothing new.
* - Two extra members exist so splits, avatars and sharing tiers render with
* real names instead of a lonely single-user state.
* - Dates sit ~2 months out so "upcoming" surfaces (What's Next, reservations)
* have something to show.
*/
const TRIP = {
title: 'Autumn in Japan',
description: 'Two weeks chasing momiji season from Tokyo down to Kyoto.',
start_date: '2026-09-12',
end_date: '2026-09-21',
currency: 'JPY',
reminder_days: 3,
}
const MEMBERS = [
{ username: 'mira', email: 'mira@example.com', password: 'DemoSeed12345!', role: 'user' },
{ username: 'jonas', email: 'jonas@example.com', password: 'DemoSeed12345!', role: 'user' },
]
/** Real coordinates — the map surfaces are a big part of what we're capturing. */
const PLACES = [
{ name: 'Senso-ji Temple', lat: 35.7148, lng: 139.7967, address: '2-3-1 Asakusa, Taito City, Tokyo',
description: "Tokyo's oldest temple, approached through the Nakamise shopping street.",
notes: 'Go before 08:00 — the gate is empty and the light is better.',
duration_minutes: 90, price: 0, currency: 'JPY', day: 0 },
{ name: 'teamLab Planets', lat: 35.6486, lng: 139.7900, address: '6-1-16 Toyosu, Koto City, Tokyo',
description: 'Immersive digital art museum you walk through barefoot.',
notes: 'Timed entry — book at least a week ahead.',
duration_minutes: 120, price: 3800, currency: 'JPY', day: 0 },
{ name: 'Shibuya Crossing', lat: 35.6595, lng: 139.7005, address: 'Shibuya City, Tokyo',
description: 'The scramble. Best viewed from the Shibuya Sky observation deck.',
duration_minutes: 45, price: 0, currency: 'JPY', day: 1 },
{ name: 'Meiji Jingu', lat: 35.6764, lng: 139.6993, address: '1-1 Yoyogikamizonocho, Shibuya City, Tokyo',
description: 'Forest shrine in the middle of the city.',
duration_minutes: 75, price: 0, currency: 'JPY', day: 1 },
{ name: 'Fushimi Inari Taisha', lat: 34.9671, lng: 135.7727, address: '68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto',
description: 'Thousands of vermilion torii gates climbing Mount Inari.',
notes: 'The crowds thin out after the first 20 minutes of climbing.',
duration_minutes: 150, price: 0, currency: 'JPY', day: 4 },
{ name: 'Arashiyama Bamboo Grove', lat: 35.0170, lng: 135.6716, address: 'Ukyo Ward, Kyoto',
description: 'Bamboo path leading to the Okochi Sanso villa gardens.',
duration_minutes: 60, price: 0, currency: 'JPY', day: 5 },
{ name: 'Nishiki Market', lat: 35.0050, lng: 135.7649, address: 'Nakagyo Ward, Kyoto',
description: "Five covered blocks of food stalls — 'Kyoto's kitchen'.",
notes: 'Come hungry. Try the tamagoyaki.',
duration_minutes: 90, price: 2500, currency: 'JPY', day: 5 },
]
const EXPENSES = [
{ name: 'Flights FRA → HND', category: 'transport', total_price: 890, currency: 'EUR',
expense_date: '2026-09-12', note: 'Booked with miles, taxes only.' },
{ name: 'Ryokan in Hakone', category: 'accommodation', total_price: 48000, currency: 'JPY',
expense_date: '2026-09-15', note: '2 nights, kaiseki dinner included.' },
{ name: 'JR Pass (14 days)', category: 'transport', total_price: 80000, currency: 'JPY',
expense_date: '2026-09-12', note: 'Green car, activated on arrival.' },
{ name: 'teamLab Planets tickets', category: 'activities', total_price: 11400, currency: 'JPY',
expense_date: '2026-09-13' },
{ name: 'Dinner at Nishiki', category: 'food', total_price: 7200, currency: 'JPY',
expense_date: '2026-09-17' },
]
const PACKING = [
{ category: 'Documents', items: ['Passport', 'JR Pass voucher', 'Travel insurance'] },
{ category: 'Clothing', items: ['Rain jacket', 'Walking shoes', 'Light layers'] },
{ category: 'Electronics', items: ['Type-A adapter', 'Power bank', 'Camera'] },
]
const TODOS = [
{ name: 'Book teamLab Planets slot', category: 'Before departure', due_date: '2026-08-15', priority: 2 },
{ name: 'Activate JR Pass', category: 'On arrival', due_date: '2026-09-12', priority: 1 },
{ name: 'Reserve ryokan dinner', category: 'Before departure', due_date: '2026-08-20' },
]
export interface SeedResult {
tripId: number
memberIds: number[]
dayIds: number[]
placeIds: number[]
collectionId?: number
journeyId?: number
}
/** Throws with the response body on failure — a silent 4xx here would produce
* a screenshot of an empty screen, which is worse than a loud crash. */
async function call<T>(api: APIRequestContext, method: 'post' | 'put' | 'get' | 'patch',
path: string, body?: unknown): Promise<T> {
const res = await api[method](path, body === undefined ? {} : { data: body })
if (!res.ok()) {
throw new Error(`${method.toUpperCase()} ${path} → ${res.status()}\n${await res.text()}`)
}
return (await res.json()) as T
}
export type ContextFactory = (token?: string) => Promise<APIRequestContext>
export async function seedDemoData(
api: APIRequestContext,
newContext?: ContextFactory,
): Promise<SeedResult> {
// 1. Addons first — the Collections and Journey guards run ahead of auth, so
// every later call to those modules 403s until these are flipped.
for (const id of ['collections', 'journey', 'packing', 'budget', 'atlas', 'vacay', 'mcp', 'documents', 'collab']) {
await call(api, 'put', `/api/admin/addons/${id}`, { enabled: true })
}
await call(api, 'put', '/api/admin/bag-tracking', { enabled: true }).catch(() => {})
// 1b. Units, pinned explicitly so the screenshots don't silently change meaning
// when a default does. They match the current defaults (ba3733da made
// celsius/metric/24h consistent across the store and the settings UI) —
// stating them here keeps the captures reproducible either way.
await call(api, 'post', '/api/settings/bulk', {
settings: { temperature_unit: 'celsius', distance_unit: 'metric' },
})
// 2. Extra members. Ignore 409 so a re-run against a warm DB still works.
const memberIds: number[] = []
for (const m of MEMBERS) {
const res = await api.post('/api/admin/users', { data: m })
if (res.ok()) {
const { user } = (await res.json()) as { user: { id: number } }
memberIds.push(user.id)
} else if (res.status() !== 409) {
throw new Error(`create user ${m.username} → ${res.status()}\n${await res.text()}`)
}
}
// 3. The trip, in JPY.
const { trip } = await call<{ trip: { id: number } }>(api, 'post', '/api/trips', TRIP)
const tripId = trip.id
for (const m of MEMBERS) {
await call(api, 'post', `/api/trips/${tripId}/members`, { identifier: m.email }).catch(() => {})
}
// 4. Days are auto-generated by trip creation — read them back for assignment.
const days = await call<Array<{ id: number }> | { days: Array<{ id: number }> }>(
api, 'get', `/api/trips/${tripId}/days`)
const dayIds = (Array.isArray(days) ? days : days.days).map(d => d.id)
// 5. Places, then pin each onto its day.
const placeIds: number[] = []
for (const p of PLACES) {
const { day, ...payload } = p
const { place } = await call<{ place: { id: number } }>(
api, 'post', `/api/trips/${tripId}/places`, payload)
placeIds.push(place.id)
const dayId = dayIds[day]
if (dayId) {
await call(api, 'post', `/api/trips/${tripId}/days/${dayId}/assignments`,
{ place_id: place.id }).catch(() => {})
}
}
// 6. A day note, so the itinerary shows more than places.
if (dayIds[0]) {
await call(api, 'post', `/api/trips/${tripId}/days/${dayIds[0]}/notes`, {
text: 'Pick up the JR Pass at the airport counter before taking the train in.',
time: '08:15', icon: 'train',
}).catch(() => {})
}
// 7. Costs. Split across everyone so the settle-up view has real balances.
// NOTE: never send exchange_rate — the server freezes the FX rate itself,
// and a hand-supplied one fights the settlement maths.
const allMembers = [1, ...memberIds]
for (const e of EXPENSES) {
await call(api, 'post', `/api/trips/${tripId}/budget`, {
...e,
payers: [{ user_id: 1, amount: e.total_price }],
member_ids: allMembers,
}).catch(() => {})
}
// A foreign-currency settle-up payment — the v3.4.0 feature worth showing.
if (memberIds[0]) {
await call(api, 'post', `/api/trips/${tripId}/budget/settlements`, {
from_user_id: memberIds[0], to_user_id: 1, amount: 120, currency: 'EUR',
}).catch(() => {})
}
// 8. Packing — category is free text on the item, there is no category resource.
for (const group of PACKING) {
for (const name of group.items) {
await call(api, 'post', `/api/trips/${tripId}/packing`, {
name, category: group.category, visibility: 'common',
}).catch(() => {})
}
}
for (const t of TODOS) {
await call(api, 'post', `/api/trips/${tripId}/todo`, t).catch(() => {})
}
// 9. A multi-leg flight. Coordinates are mandatory — endpoints without them
// are silently dropped by the server, leaving a booking with no route.
await call(api, 'post', `/api/trips/${tripId}/reservations`, {
title: 'LH716 FRA → HND',
type: 'flight',
reservation_time: '2026-09-12T13:05:00',
reservation_end_time: '2026-09-13T08:25:00',
confirmation_number: 'X7K2QP',
status: 'confirmed',
location: 'Frankfurt Airport',
metadata: { airline: 'Lufthansa', flight_number: 'LH716',
departure_airport: 'FRA', arrival_airport: 'HND' },
endpoints: [
{ role: 'from', sequence: 0, name: 'Frankfurt Airport', code: 'FRA',
lat: 50.0379, lng: 8.5622, timezone: 'Europe/Berlin',
local_date: '2026-09-12', local_time: '13:05' },
{ role: 'to', sequence: 1, name: 'Tokyo Haneda', code: 'HND',
lat: 35.5494, lng: 139.7798, timezone: 'Asia/Tokyo',
local_date: '2026-09-13', local_time: '08:25' },
],
}).catch(() => {})
// 10. A collection, populated from the trip's own places.
let collectionId: number | undefined
try {
const created = await call<{ id: number } | { collection: { id: number } }>(
api, 'post', '/api/addons/collections',
{ name: 'Kyoto shortlist', description: 'Places we want to reach on the second week.',
color: '#ef4444', icon: 'MapPin' })
collectionId = 'id' in created ? created.id : created.collection.id
for (const placeId of placeIds.slice(4)) {
await call(api, 'post', '/api/addons/collections/places/from-trip', {
collection_id: collectionId, source_trip_id: tripId, source_place_id: placeId, force: true,
}).catch(() => {})
}
} catch { /* collections addon unavailable — screenshots for it will be skipped */ }
// 11. Journey. Entries are generated server-side from the trip, then filled in.
let journeyId: number | undefined
try {
const j = await call<{ id: number } | { journey: { id: number } }>(
api, 'post', '/api/journeys',
{ title: 'Autumn in Japan', subtitle: 'Momiji season, Tokyo to Kyoto', trip_ids: [tripId] })
journeyId = 'id' in j ? j.id : j.journey.id
} catch { /* journey addon unavailable */ }
// 11b. Collab: chat, notes and polls.
//
// Chat is only convincing with more than one voice, and every collab
// write is attributed to the acting user — so messages and votes are
// posted as the members themselves, via their own bearer tokens, not as
// the admin. A single-speaker chat log would misrepresent the feature.
// Each member gets its OWN request context. Logging in through the shared
// one would set the trek_session cookie on it, and extractToken()
// (server/src/middleware/auth.ts:9) reads the cookie BEFORE the
// Authorization header — so every later write, including the admin's,
// would silently be attributed to whoever logged in last.
const members: Record<string, APIRequestContext> = {}
for (const m of MEMBERS) {
if (!newContext) break
const anon = await newContext()
const res = await anon.post('/api/auth/login', { data: { email: m.email, password: m.password } })
if (!res.ok()) { await anon.dispose(); continue }
const { token } = (await res.json()) as { token?: string }
await anon.dispose()
if (token) members[m.username] = await newContext(token)
}
/** The member's own context, or the admin's as a visible fallback. */
const as = (username: string): APIRequestContext => members[username] ?? api
const collab = `/api/trips/${tripId}/collab`
for (const n of [
{ title: 'Rail passes', category: 'Transport', color: '#3b82f6',
content: 'The 14-day JR Pass covers the Tokyo–Kyoto legs. Activate it at the airport counter on arrival, not before.' },
{ title: 'Ryokan etiquette', category: 'Accommodation', color: '#ef4444',
content: 'Shoes off at the entrance, yukata for dinner. Dinner is served at 18:30 sharp — being late is genuinely rude.' },
{ title: 'Rainy-day alternatives', category: 'Ideas', color: '#22c55e',
content: 'teamLab Planets, the Kyoto Railway Museum and Nishiki Market all work in bad weather.' },
]) {
await api.post(`${collab}/notes`, { data: n }).catch(() => {})
}
const pollRes = await api.post(`${collab}/polls`, {
data: {
question: 'Which day should we keep free for Nara?',
options: ['Wed, Sep 16', 'Thu, Sep 17', 'Sat, Sep 19'],
multiple: false,
},
})
if (pollRes.ok()) {
const { poll } = (await pollRes.json()) as { poll: { id: number | string } }
await api.post(`${collab}/polls/${poll.id}/vote`, { data: { option_index: 1 } }).catch(() => {})
await as('mira').post(`${collab}/polls/${poll.id}/vote`, { data: { option_index: 1 } }).catch(() => {})
await as('jonas').post(`${collab}/polls/${poll.id}/vote`, { data: { option_index: 2 } }).catch(() => {})
}
await api.post(`${collab}/polls`, {
data: { question: 'Ryokan or city hotel in Hakone?', options: ['Ryokan with onsen', 'City hotel'], multiple: false },
}).catch(() => {})
const conversation: Array<[string, string]> = [
['admin', 'Flights are booked — we land at Haneda 08:25 on the 13th.'],
['mira', 'Nice. Should we go straight to the hotel or drop bags and head out?'],
['jonas', 'Drop bags. I want to be at Senso-ji before the crowds.'],
['admin', "Agreed. I've put it on day 1 with a note to go before 08:00."],
['mira', 'Booked the teamLab slot for the 13th, 14:00. Tickets are in the Files tab.'],
['jonas', 'Do we need to reserve the ryokan dinner separately?'],
['admin', "It's included — kaiseki, 18:30. Added it to the to-dos so we don't forget to confirm."],
]
for (const [who, text] of conversation) {
const ctx = who === 'admin' ? api : as(who)
await ctx.post(`${collab}/messages`, { data: { text } }).catch(() => {})
}
for (const ctx of Object.values(members)) await ctx.dispose()
// 12. Plugins, installed from the community registry.
//
// Registry install is the ONLY path that produces a representative
// screenshot. Dev-link and sideload both stamp the plugin card with a
// badge ("Dev-Link" / "Sideloaded", AdminPluginsPanel.tsx:307,361) that no
// ordinary install shows, and TREK_PLUGINS_DEV_LINK additionally reveals a
// "Link a local plugin" row in the panel. Documenting either would show
// readers a UI they will never have.
//
// Needs network. If the registry is unreachable the plugin screenshots are
// skipped loudly rather than silently captured in a misleading state.
for (const id of ['koffi', 'trip-doctor']) {
const res = await api.post('/api/admin/plugins/install', { data: { id } })
if (!res.ok()) {
console.log(`PLUGIN INSTALL FAILED ${id} → ${res.status()} ${await res.text()}`)
continue
}
await api.post(`/api/admin/plugins/${id}/activate`, { data: {} })
}
return { tripId, memberIds, dayIds, placeIds, collectionId, journeyId }
}
+105
View File
@@ -0,0 +1,105 @@
import { test, clearNotices } from './shot'
import type { Page } from '@playwright/test'
/**
* Settings and Admin tabs.
*
* Both pages use the shared PageSidebar with client-side tab state (no URL
* segment per tab), so each capture clicks its way in. Labels come from
* shared/src/i18n/en — note "General" is the tab the wiki still calls
* "Display", which is one of the corrections this screenshot run supports.
*/
async function openSidebarTab(page: Page, label: string) {
await page.getByRole('button', { name: label, exact: true }).first().click()
await page.waitForTimeout(600)
}
test.describe('user settings', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/settings')
await clearNotices(page)
})
// Filename kept as UsrSettings.png — the wiki already references it.
test('general tab', async ({ page, shot }) => {
await openSidebarTab(page, 'General')
await shot.page_('UsrSettings')
})
test('appearance tab', async ({ page, shot }) => {
await openSidebarTab(page, 'Appearance')
await shot.page_('UsrSettingsAppearance')
})
test('map tab', async ({ page, shot }) => {
await openSidebarTab(page, 'Map')
await shot.page_('UsrSettingsMap')
})
test('notifications tab', async ({ page, shot }) => {
await openSidebarTab(page, 'Notifications')
await shot.page_('NotifSettings')
})
test('offline tab', async ({ page, shot }) => {
await openSidebarTab(page, 'Offline')
await shot.page_('SettingsOffline')
})
test('account tab', async ({ page, shot }) => {
await openSidebarTab(page, 'Account')
await shot.page_('SettingsAccount')
})
})
test.describe('admin panel', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/admin')
await clearNotices(page)
})
test('users', async ({ page, shot }) => {
await openSidebarTab(page, 'Users')
await shot.page_('UsersAndInvites')
})
test('user defaults', async ({ page, shot }) => {
await openSidebarTab(page, 'User Defaults')
await shot.page_('AdminUserDefaults')
})
test('personalization', async ({ page, shot }) => {
await openSidebarTab(page, 'Personalization')
await shot.page_('CategoryManager')
})
test('addons', async ({ page, shot }) => {
await openSidebarTab(page, 'Addons')
await shot.page_('Addons-Overview')
})
test('plugins', async ({ page, shot }) => {
await openSidebarTab(page, 'Plugins')
await shot.page_('AdminPlugins')
})
test('github releases', async ({ page, shot }) => {
await openSidebarTab(page, 'GitHub')
await shot.page_('GithubReleases')
})
test('backup', async ({ page, shot }) => {
await openSidebarTab(page, 'Backup')
await shot.page_('Backup')
})
test('audit log', async ({ page, shot }) => {
await openSidebarTab(page, 'Audit')
await shot.page_('Audit')
})
test('admin panel overview', async ({ page, shot }) => {
await shot.page_('AdminPanel')
})
})
+119
View File
@@ -0,0 +1,119 @@
import { test as base, expect, type Page, type Locator } from '@playwright/test'
import { mkdirSync } from 'node:fs'
import path from 'node:path'
/**
* Shared plumbing for the documentation screenshot run (`npm run shots`).
*
* These are not assertions about behaviour — they drive the app to a known
* state and capture it for the wiki. They live behind their own Playwright
* project (`screenshots`, testMatch /\.shot\.ts/) so a normal `npm run e2e`
* never pays for them.
*
* Output goes to a staging directory, NOT straight into wiki/assets/, so a
* bad run can never clobber good artwork. Promote with `npm run shots:promote`.
*/
// Playwright runs from the client workspace root, matching how
// playwright.config.ts spells `storageState: 'e2e/.tmp/state.json'`.
export const OUT_DIR = path.join(process.cwd(), 'e2e', '.tmp', 'shots')
/** Desktop capture size. 2x scale keeps text crisp; images are squeezed on promote. */
export const VIEWPORT = { width: 1440, height: 900 }
export const test = base.extend<{ shot: Shot }>({
// Overriding `page` (rather than doing this inside the `shot` fixture) is
// deliberate: fixtures initialise lazily, so a route registered in `shot`
// lands AFTER any beforeEach hook has already navigated — too late to
// intercept the config request.
page: async ({ page }, use) => {
await page.setViewportSize(VIEWPORT)
await hideDevOnlyUi(page)
await use(page)
},
shot: async ({ page }, use) => {
mkdirSync(OUT_DIR, { recursive: true })
await use(new Shot(page))
},
})
/**
* The E2E backend runs with NODE_ENV=development, so /auth/app-config reports
* `dev_mode: true` (authService.ts) and the admin sidebar grows a
* "Dev: Notifications" tab that no real deployment ever shows.
*
* Rewriting the response is the surgical fix. Flipping the server to
* NODE_ENV=production would also enable HSTS (globalMiddleware.ts), and an
* HSTS header on localhost would upgrade the run to https and break it.
*/
async function hideDevOnlyUi(page: Page): Promise<void> {
await page.route('**/api/auth/app-config', async route => {
const res = await route.fetch()
const body = await res.json()
await route.fulfill({ response: res, json: { ...body, dev_mode: false } })
})
}
export { expect }
export class Shot {
constructor(private readonly page: Page) {}
/**
* Capture the full viewport. `name` is the target filename in wiki/assets/
* (without extension) so the mapping from screenshot to doc page is literal.
*/
async page_(name: string): Promise<void> {
await this.settle()
await this.page.screenshot({ path: path.join(OUT_DIR, `${name}.png`) })
}
/** Capture one element — preferred for dialogs, panels and cards. */
async element(name: string, target: Locator): Promise<void> {
await this.settle()
await expect(target).toBeVisible()
await target.screenshot({ path: path.join(OUT_DIR, `${name}.png`) })
}
/**
* Quiet the page before capturing: fonts loaded, images decoded, animations
* finished, no pending network. Without this, screenshots catch skeleton
* loaders and half-faded modals, which is exactly how the current wiki
* assets ended up inconsistent.
*/
private async settle(): Promise<void> {
// Bounded: TREK holds a WebSocket open at /ws, so the network never goes
// fully idle and an unbounded wait would burn the whole test timeout.
await this.page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {})
// Await, but return nothing — the resolved FontFaceSet is not serialisable.
await this.page.evaluate(async () => { await document.fonts.ready })
await this.page.evaluate(async () => {
await Promise.all(
Array.from(document.images)
.filter(img => !img.complete)
.map(img => new Promise(res => { img.onload = img.onerror = res })),
)
})
// Let CSS transitions land (modal fade-in, sidebar slide).
await this.page.waitForTimeout(400)
}
}
/**
* Dismiss the first-run system notice. Copied in spirit from e2e/helpers.ts,
* but tolerant: on a seeded DB the notice may already be cleared.
*/
export async function clearNotices(page: Page): Promise<void> {
const next = page.getByRole('button', { name: /next/i })
for (let i = 0; i < 6 && (await next.isVisible().catch(() => false)); i++) {
if (!(await next.isEnabled().catch(() => false))) break
await next.click().catch(() => {})
}
for (const label of ['Dismiss', 'OK']) {
const btn = page.getByRole('button', { name: label, exact: true })
for (let i = 0; i < 4 && (await btn.isVisible().catch(() => false)); i++) {
await btn.click().catch(() => {})
await page.waitForTimeout(300)
}
}
}
+8 -2
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Open a trip into the planner: create a trip, open it from the dashboard, and
// confirm the trip planner (TripPlannerPage — the app's largest page) actually
@@ -6,12 +7,17 @@ import { test, expect } from '@playwright/test'
test('open a trip and land in the planner with a map', async ({ page }) => {
await page.goto('/dashboard')
// The release notice greets a freshly seeded user and its backdrop eats the click below.
await dismissSystemNotices(page)
// Create a trip to open.
await page.locator('.add-trip-card').click()
const modal = page.locator('.modal-backdrop')
const modal = page.locator('.trek-modal-backdrop')
await expect(modal).toBeVisible()
// Target Title by placeholder: the cover-image search inputs sit above it, so
// input[type=text].first() is the photo search box, not the field we want.
const title = `E2E Planner ${Date.now()}`
await modal.locator('input[type="text"]').first().fill(title)
await modal.getByPlaceholder('e.g. Summer in Japan').fill(title)
await modal.getByRole('button', { name: 'Create New Trip' }).click()
// Open it from the dashboard.
+105
View File
@@ -0,0 +1,105 @@
import { test, expect, devices } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Phone regression guard for #1809: the dashboard must scroll the DOCUMENT.
//
// iOS Safari minimises its address bar only in reaction to the root scroller
// moving. V4 blocked that twice over: html was globally overflow:hidden and the
// mobile shell added a scroll container of its own, so the gesture never
// reached the viewport and the bar kept ~1/6th of the screen forever. The bar
// itself is not observable from page context, but the invariant it hangs off is:
// document.scrollingElement has to move.
//
// Needs WebKit (`npx playwright install webkit`). playwright.config.ts has no
// webkit project, the engine comes from this file-level device (defaultBrowserType
// 'webkit'), same as e2e/ipad-scroll-1432.spec.ts. WebKit is the point here, not
// a nicety: viewport propagation and the toolbar behaviour are Safari's.
//
// The file sorts last in the e2e run, so the trips it seeds cannot show up in
// another spec's dashboard assertions.
test.use({ ...devices['iPhone 14'] })
function readScroll() {
const el = document.scrollingElement as HTMLElement
return {
top: el.scrollTop,
scrollHeight: el.scrollHeight,
viewport: window.innerHeight,
htmlOverflow: getComputedStyle(document.documentElement).overflowY,
}
}
test('#1809 iPhone: flow screens scroll the document, full-screen ones do not', async ({ page }) => {
await page.goto('/dashboard')
await dismissSystemNotices(page)
// The context has to be the one from the report: phone width, coarse pointer.
// If either is wrong, nothing below proves anything.
const env = await page.evaluate(() => ({
width: window.innerWidth,
coarse: window.matchMedia('(pointer: coarse)').matches,
}))
expect(env.width, 'iPhone sits below the 768px phone breakpoint').toBeLessThan(768)
expect(env.coarse, 'iPhone reports a coarse primary pointer').toBe(true)
// Enough trips for the dashboard to overflow a phone screen.
const stamp = Date.now()
for (let i = 1; i <= 20; i++) {
const res = await page.request.post('/api/trips', {
data: { title: `URL bar ${stamp} ${i}`, start_date: '2099-06-01', end_date: '2099-06-10' },
})
expect(res.ok(), `seed trip ${i}`).toBeTruthy()
}
await page.reload()
await dismissSystemNotices(page)
await expect(page.getByText(`URL bar ${stamp} 1`).first()).toBeVisible({ timeout: 20_000 })
// 1. The root scroller is unlocked and the page is longer than the viewport.
const before = await page.evaluate(readScroll)
expect(before.htmlOverflow, 'html must not lock the viewport scroller').not.toBe('hidden')
expect(before.scrollHeight, 'dashboard is longer than one viewport').toBeGreaterThan(before.viewport)
expect(before.top).toBe(0)
// 2. Scrolling moves the document, the signal Safari retracts its bar for.
await page.evaluate(() => window.scrollBy(0, 400))
const scrolled = await page.evaluate(readScroll)
expect(scrolled.top, 'the document itself scrolled').toBeGreaterThan(0)
// 3. The brand tile scrolls the page back up (it used to walk up to the shell
// container, which no longer exists).
await page.getByRole('button', { name: 'TREK' }).click()
await expect.poll(() => page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(0)
// 4. The shared body scroll lock: with the document as the scroller, an open
// sheet must freeze the page behind it and give the position back.
await page.evaluate(() => window.scrollBy(0, 300))
const locked = await page.evaluate(readScroll)
expect(locked.top).toBeGreaterThan(0)
await page.getByRole('button', { name: 'New Trip' }).click()
await expect(page.getByRole('dialog')).toBeVisible()
await page.evaluate(() => window.scrollBy(0, 300))
expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(locked.top)
await page.keyboard.press('Escape')
await expect(page.getByRole('dialog')).toHaveCount(0)
expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(locked.top)
await page.evaluate(() => window.scrollBy(0, 200))
expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBeGreaterThan(locked.top)
// 5. Counter-probe: the map screens keep their own full-viewport layout. They
// deliberately do NOT scroll the document (and so keep the address bar), and
// the map must not collapse to zero height now that the shell has none.
await page.goto('/atlas')
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
const atlas = await page.evaluate(() => {
const map = document.querySelector('.leaflet-container') as HTMLElement
window.scrollBy(0, 400)
return {
top: document.scrollingElement!.scrollTop,
mapHeight: map.getBoundingClientRect().height,
}
})
expect(atlas.top, 'the atlas does not move the document').toBe(0)
expect(atlas.mapHeight, 'the atlas map keeps a real height').toBeGreaterThan(200)
})
+21
View File
@@ -75,4 +75,25 @@ export default tseslint.config(
'preserve-caught-error': 'warn',
},
},
{
// react-dom/server was worth ~190 KB raw / 57 KB gzip in a chunk three lazy
// routes share — including the Leaflet renderer, which is the default — for
// output that is always a single <svg>. utils/iconMarkup.ts does that job
// without Fizz, and this keeps the import from creeping back: nothing else
// would notice, the build stays green and the app keeps working.
files: ['src/**/*.{ts,tsx}'],
ignores: ['src/**/*.test.{ts,tsx}'],
rules: {
'no-restricted-imports': ['error', {
paths: [{
name: 'react-dom/server',
message: 'Use renderIconMarkup from utils/iconMarkup — Fizz is ~190 KB for one <svg>.',
}],
}],
'no-restricted-syntax': ['error', {
selector: "ImportExpression > Literal[value=/^react-dom\\u002F(server|static)/]",
message: 'Use renderIconMarkup from utils/iconMarkup — Fizz is ~190 KB for one <svg>.',
}],
},
},
);
+5 -9
View File
@@ -5,6 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>TREK</title>
<!-- Pre-paint appearance (FOUC fix). External classic script so it runs
before first paint AND complies with the prod CSP (script-src 'self'). -->
<script src="/theme-boot.js"></script>
<!-- PWA / iOS -->
<meta name="theme-color" content="#09090b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
@@ -15,15 +19,7 @@
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
<!-- Fonts are bundled from @fontsource in src/main.tsx, see the note there. -->
</head>
<body>
<div id="root"></div>
+22 -7
View File
@@ -1,12 +1,13 @@
{
"name": "@trek/client",
"version": "3.1.3",
"version": "4.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "node scripts/generate-icons.mjs",
"build": "vite build",
"build:analyze": "vite build --mode analyze",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run",
@@ -17,39 +18,53 @@
"lint": "eslint .",
"lint:check": "eslint .",
"lint:pages": "node scripts/check-page-pattern.mjs",
"check:gl-split": "node scripts/check-gl-split.mjs",
"theme:lint": "node scripts/theme-lint.mjs",
"theme:lint:strict": "node scripts/theme-lint.mjs --strict",
"e2e": "playwright test",
"shots": "playwright test --project=screenshots",
"shots:promote": "node e2e/screenshots/promote.mjs",
"e2e:report": "playwright show-report",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
},
"dependencies": {
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/geist-sans": "^5.2.5",
"@fontsource/inter": "^5.3.0",
"@fontsource/lora": "^5.3.0",
"@fontsource/museomoderno": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.2.7",
"@react-pdf/renderer": "^4.5.1",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@simplewebauthn/browser": "^13.1.2",
"@trek/shared": "*",
"axios": "^1.6.7",
"dexie": "^4.4.2",
"drag-drop-touch": "^1.3.1",
"heic-to": "^1.4.2",
"iso-3166-2": "^1.0.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
"maplibre-gl": "^5.24.0",
"marked": "^18.0.0",
"plyr": "^3.8.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-dropzone": "^14.4.1",
"react-leaflet": "^5.0.0",
"react-leaflet-cluster": "^4.1.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.2",
"react-window": "^2.2.7",
"react-router": "^7.18.2",
"rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
"tz-lookup": "^6.1.25",
"zod": "^4.3.6",
"zustand": "^4.5.2"
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -62,7 +77,6 @@
"@types/node": "^25.9.3",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.9",
"autoprefixer": "^10.4.18",
@@ -78,7 +92,8 @@
"prettier": "^3.8.3",
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"sharp": "^0.33.0",
"rollup-plugin-visualizer": "^7.0.1",
"sharp": "^0.35.0",
"tailwindcss": "^3.4.1",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.2",
+22
View File
@@ -35,6 +35,28 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' },
dependencies: ['setup'],
},
// Documentation screenshots (`npm run shots`). Excluded from the normal e2e
// run by its own testMatch — these capture artwork for wiki/assets/, they
// assert nothing. 2x scale keeps text crisp at the sizes the wiki renders.
// Populates the demo trip the screenshots are taken of. Separate project so
// it runs exactly once, between auth and capture.
{
name: 'seed',
testMatch: /seed\.setup\.ts/,
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' },
dependencies: ['setup'],
},
{
name: 'screenshots',
testMatch: /\.shot\.ts/,
use: {
...devices['Desktop Chrome'],
storageState: 'e2e/.tmp/state.json',
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2,
},
dependencies: ['seed'],
},
],
webServer: [
{
+58
View File
@@ -0,0 +1,58 @@
/*
* Pre-paint appearance boot — kills the flash of default/wrong theme (FOUC).
*
* Loaded as an external, render-blocking CLASSIC script in <head> (NOT a module)
* so it runs before first paint AND complies with the production CSP
* (script-src 'self'; inline scripts are blocked). It reads the compact snapshot
* written by client/src/theme/applyAppearance.ts and applies it verbatim. Keep
* this in sync with that module's snapshot shape + apply logic.
*
* It must never throw — any failure silently falls back to the default look.
*/
(function () {
try {
var raw = localStorage.getItem('trek_appearance');
if (!raw) return;
var s = JSON.parse(raw);
if (!s || s.v !== 1) return;
var root = document.documentElement;
var path = location.pathname;
var isShared = path.indexOf('/shared/') === 0 || path.indexOf('/public/') === 0;
var dark;
if (isShared) dark = false;
else if (s.darkMode === 'auto') dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
else dark = s.darkMode === true || s.darkMode === 'dark';
root.classList.toggle('dark', dark);
var scheme = isShared ? 'default' : s.scheme;
if (scheme && scheme !== 'default') root.setAttribute('data-scheme', scheme);
if (!isShared && s.noTransparency) root.setAttribute('data-no-transparency', '');
if (s.density === 'compact') root.setAttribute('data-density', 'compact');
if (s.reduceMotion) root.setAttribute('data-reduce-motion', '');
if (!isShared && scheme === 'custom' && s.accent) {
root.style.setProperty('--accent-custom-light', s.accent.light);
root.style.setProperty('--accent-custom-dark', s.accent.dark);
if (s.accentText) {
root.style.setProperty('--accent-custom-text-light', s.accentText.light);
root.style.setProperty('--accent-custom-text-dark', s.accentText.dark);
}
}
var ts = s.typeScale || {};
var fs = typeof s.fontScale === 'number' ? s.fontScale : 1;
setScale('--fs-scale-title', fs * (ts.title || 1));
setScale('--fs-scale-subtitle', fs * (ts.subtitle || 1));
setScale('--fs-scale-body', fs * (ts.body || 1));
setScale('--fs-scale-caption', fs * (ts.caption || 1));
if (fs !== 1) root.style.fontSize = fs * 100 + '%';
function setScale(name, v) {
if (typeof v === 'number' && v !== 1) root.style.setProperty(name, String(v));
}
} catch (e) {
/* never block boot */
}
})();
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env node
/**
* Country silhouettes for TREK Studio, built from the boundaries the server
* already bundles for Atlas.
*
* node scripts/build-country-shapes.mjs
*
* Writes `src/components/Studio/countryShapes.ts`. Run it when
* `server/assets/atlas/admin0.geojson.gz` changes; the output is committed, so
* a normal build never runs this.
*
* ── Why a second, tiny copy of the boundaries ─────────────────────────────
*
* Atlas's admin-0 file is 4.3MB gzipped and ~30MB parsed, sized for a slippy
* map you can zoom into. A book page draws a country at 20mm across, where the
* ten thousand points describing a fjord land inside one printer dot. Shipping
* the map data to draw a stamp would cost more than the entire Studio bundle.
*
* So: project, drop the islands nobody can see at that size, simplify hard, and
* normalise each country into its own 0..100 box. What comes out is a few
* hundred bytes per country and prints identically, because at 20mm it *is*
* identical.
*
* ── The three things that are easy to get wrong ───────────────────────────
*
* 1. **Project before simplifying.** Simplifying in degrees and projecting
* afterwards removes the wrong points: a degree of longitude is 111km at the
* equator and 30km at Reykjavík, so a fixed tolerance eats Iceland's coast
* while leaving Kenya's untouched.
* 2. **Countries that cross the antimeridian.** Russia and Fiji have parts at
* +179 and -179. Taken literally their bounding box is the whole world and
* the silhouette is a horizontal smear. Rings that span more than half the
* globe get their negative longitudes lifted by 360 so the country is
* contiguous again.
* 3. **Islands.** Keeping every ring turns Norway into a dust cloud and Greece
* into static. Rings below a fraction of the largest one are dropped — the
* mainland is what makes a country recognisable at a glance.
*/
import fs from 'node:fs'
import path from 'node:path'
import url from 'node:url'
import zlib from 'node:zlib'
const HERE = path.dirname(url.fileURLToPath(import.meta.url))
const SRC = path.join(HERE, '..', '..', 'server', 'assets', 'atlas', 'admin0.geojson.gz')
const OUT = path.join(HERE, '..', 'src', 'components', 'Studio', 'countryShapes.ts')
/** Rings smaller than this share of the biggest one are not drawn. */
const ISLAND_CUTOFF = 0.012
/** Never draw more than this many rings for one country. */
const MAX_RINGS = 14
/** Point budget per country, across every ring it keeps. */
const MAX_POINTS = 190
/** Web Mercator, unscaled. y is flipped so it grows downwards like SVG's. */
function project([lng, lat]) {
const clamped = Math.max(-85.05, Math.min(85.05, lat))
const rad = (clamped * Math.PI) / 180
return [lng, -(Math.log(Math.tan(Math.PI / 4 + rad / 2)) * 180) / Math.PI]
}
function ringArea(ring) {
let a = 0
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
a += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1]
}
return Math.abs(a / 2)
}
/** Perpendicular distance from p to the segment ab, squared. */
function segDistSq(p, a, b) {
let x = a[0], y = a[1]
let dx = b[0] - x, dy = b[1] - y
if (dx !== 0 || dy !== 0) {
const t = ((p[0] - x) * dx + (p[1] - y) * dy) / (dx * dx + dy * dy)
if (t > 1) { x = b[0]; y = b[1] } else if (t > 0) { x += dx * t; y += dy * t }
}
dx = p[0] - x
dy = p[1] - y
return dx * dx + dy * dy
}
/** Douglas-Peucker, iterative so a long coastline cannot blow the stack. */
function simplify(points, tolerance) {
if (points.length < 3) return points
const tolSq = tolerance * tolerance
const keep = new Uint8Array(points.length)
keep[0] = 1
keep[points.length - 1] = 1
const stack = [[0, points.length - 1]]
while (stack.length) {
const [first, last] = stack.pop()
let maxSq = 0
let index = 0
for (let i = first + 1; i < last; i++) {
const d = segDistSq(points[i], points[first], points[last])
if (d > maxSq) { maxSq = d; index = i }
}
if (maxSq > tolSq) {
keep[index] = 1
stack.push([first, index], [index, last])
}
}
return points.filter((_, i) => keep[i])
}
/** Every outer ring of a Polygon or MultiPolygon, holes discarded. */
function outerRings(geometry) {
if (!geometry) return []
if (geometry.type === 'Polygon') return geometry.coordinates.slice(0, 1)
if (geometry.type === 'MultiPolygon') return geometry.coordinates.map(poly => poly[0])
return []
}
/**
* Lift a ring that straddles the antimeridian into a continuous longitude
* range. A ring whose points span more than half the globe is, in practice,
* always a wrap rather than a genuinely hemisphere-wide landmass.
*/
function unwrap(ring) {
let min = Infinity
let max = -Infinity
for (const [lng] of ring) { if (lng < min) min = lng; if (lng > max) max = lng }
if (max - min <= 180) return ring
return ring.map(([lng, lat]) => [lng < 0 ? lng + 360 : lng, lat])
}
function build(feature) {
const rings = outerRings(feature.geometry)
.map(unwrap)
.map(ring => ring.map(project))
.map(ring => ({ ring, area: ringArea(ring) }))
.sort((a, b) => b.area - a.area)
if (!rings.length) return null
const biggest = rings[0].area
const kept = rings
.filter(r => r.area >= biggest * ISLAND_CUTOFF)
.slice(0, MAX_RINGS)
.map(r => r.ring)
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
for (const ring of kept) {
for (const [x, y] of ring) {
if (x < minX) minX = x
if (x > maxX) maxX = x
if (y < minY) minY = y
if (y > maxY) maxY = y
}
}
const spanX = maxX - minX
const spanY = maxY - minY
if (!(spanX > 0) || !(spanY > 0)) return null
// The longer side becomes 100 and the shorter one keeps its proportion, so a
// country never has to be squashed to fit the box it is drawn in.
const scale = 100 / Math.max(spanX, spanY)
const w = spanX * scale
const h = spanY * scale
const normalised = kept.map(ring =>
ring.map(([x, y]) => [(x - minX) * scale, (y - minY) * scale]))
// Tolerance climbs until the whole country fits its point budget. Starting
// from the diagonal keeps the loop short for a country of any size.
let tolerance = 0.14
let simplified = normalised.map(r => simplify(r, tolerance))
let guard = 0
while (simplified.reduce((n, r) => n + r.length, 0) > MAX_POINTS && guard++ < 40) {
tolerance *= 1.35
simplified = normalised.map(r => simplify(r, tolerance))
}
const d = simplified
.filter(ring => ring.length >= 3)
.map(ring => `M${ring.map(([x, y]) => `${round(x)} ${round(y)}`).join('L')}Z`)
.join('')
// The projected bounds, kept so the same path can be replayed in world
// coordinates. A country drawn on its own wants its own box; the same country
// on a route map has to sit where it actually is, next to its neighbours. One
// normalised path plus these four numbers serves both, where storing two sets
// of coordinates would be twice the bytes and one more thing to disagree.
return d ? { d, w: round(w), h: round(h), b: [minX, minY, maxX, maxY].map(v => Math.round(v * 100) / 100) } : null
}
const round = n => Math.round(n * 10) / 10
const raw = zlib.gunzipSync(fs.readFileSync(SRC))
const collection = JSON.parse(raw.toString('utf8'))
const shapes = new Map()
for (const feature of collection.features) {
const code = String(feature.properties?.ISO_A2 || '').toUpperCase()
if (code.length !== 2 || code === '-9') continue
const built = build(feature)
if (!built) continue
// A code appearing twice means two features for one country; keep the one
// with more outline, which is the mainland rather than a dependency.
const prev = shapes.get(code)
if (!prev || built.d.length > prev.d.length) shapes.set(code, built)
}
const codes = [...shapes.keys()].sort()
const body = codes
.map(code => {
const s = shapes.get(code)
return ` ${code}: { d: '${s.d}', w: ${s.w}, h: ${s.h}, b: [${s.b.join(', ')}] },`
})
.join('\n')
const file = `/**
* GENERATED — do not edit. Run \`node scripts/build-country-shapes.mjs\`.
*
* Country silhouettes, simplified from the admin-0 boundaries the server
* bundles for Atlas (geoBoundaries, CC BY 4.0). Each path is Web Mercator,
* normalised so the longer side is 100 units; \`w\` and \`h\` give the shape's
* own proportions inside that box so it can be drawn without being squashed.
*
* ${codes.length} countries, built for print at postage-stamp size — islands
* below ${ISLAND_CUTOFF * 100}% of the mainland are dropped and each outline is
* simplified to at most ${MAX_POINTS} points.
*/
export interface CountryShape {
/** Path on a 0..100 box, outer rings only. */
d: string
/** The shape's own width and height inside that box. */
w: number
h: number
/** Projected bounds \`[minX, minY, maxX, maxY]\`, for drawing it in place. */
b: [number, number, number, number]
}
/**
* Web Mercator, matching the projection the paths were built with. Longitude
* passes through as degrees; latitude is flipped so y grows downwards like
* SVG's does.
*/
export function projectMercator(lng: number, lat: number): { x: number; y: number } {
const clamped = Math.max(-85.05, Math.min(85.05, lat))
const rad = (clamped * Math.PI) / 180
return { x: lng, y: -(Math.log(Math.tan(Math.PI / 4 + rad / 2)) * 180) / Math.PI }
}
/**
* The same outline, placed in projected world coordinates.
*
* The stored path is normalised into its own box; multiplying by the box's span
* and shifting by its origin puts it back where the country actually is, which
* is what a map with more than one country on it needs.
*/
export function countryWorldPath(shape: CountryShape): string {
const span = Math.max(shape.b[2] - shape.b[0], shape.b[3] - shape.b[1]) / 100
let axis = 0
return shape.d.replace(/-?\\d*\\.?\\d+/g, m => {
const isX = axis++ % 2 === 0
const v = Number(m) * span + (isX ? shape.b[0] : shape.b[1])
return String(Math.round(v * 1000) / 1000)
})
}
export const COUNTRY_SHAPES: Record<string, CountryShape> = {
${body}
}
/** The silhouette for an ISO-3166-1 alpha-2 code, or null if we have none. */
export function countryShape(code: string | null | undefined): CountryShape | null {
if (!code) return null
return COUNTRY_SHAPES[code.toUpperCase()] ?? null
}
`
fs.writeFileSync(OUT, file, 'utf8')
const kb = Math.round(Buffer.byteLength(file) / 102.4) / 10
console.log(`[country-shapes] ${codes.length} countries, ${kb} kB → ${path.relative(process.cwd(), OUT)}`)
+47
View File
@@ -0,0 +1,47 @@
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
/**
* Fails when a built chunk contains both WebGL map engines.
*
* mapbox-gl and maplibre-gl used to be static imports in the same three
* components, so rollup emitted a single 2.8 MB chunk carrying both. Every map
* user downloaded 1.8 MB of mapbox plus 1.1 MB of maplibre and ran one of them.
* A stray static import anywhere in the map tree brings that straight back, and
* nothing else would notice — the build stays green and the app still works.
*
* The markers are chosen to appear only inside the SDKs themselves, verified in
* node_modules. Do NOT test for the bare string "maplibre": the provider value
* 'maplibre-gl' and the setting key maplibre_style also appear in app code, so it
* matches the shared core chunk too.
*/
const DIR = 'dist/assets'
const MAPBOX = 'events.mapbox.com' // 1 hit in mapbox-gl, 0 in maplibre-gl
const MAPLIBRE = 'maplibregl-canvas' // 2 hits in maplibre-gl, 0 in mapbox-gl
let mixed = 0
let found = 0
for (const file of readdirSync(DIR).filter(f => f.endsWith('.js'))) {
const path = join(DIR, file)
const src = readFileSync(path, 'utf8')
const hasMapbox = src.includes(MAPBOX)
const hasMaplibre = src.includes(MAPLIBRE)
if (!hasMapbox && !hasMaplibre) continue
found++
const size = statSync(path).size
if (hasMapbox && hasMaplibre) {
console.error(`FAIL both GL engines in one chunk: ${file} (${size} B)`)
mixed++
} else {
console.log(`${hasMapbox ? 'mapbox ' : 'maplibre'} ${file} ${size} B`)
}
}
if (!found) {
console.error('FAIL no chunk contains either GL engine — did the build run?')
process.exit(1)
}
process.exit(mixed ? 1 : 0)
+32 -14
View File
@@ -2,9 +2,12 @@
// src/pages/PATTERN.md). A *Page.tsx default-export component should wire a
// co-located use<Page>() hook into JSX — it must not own state/effects itself.
//
// We scan only the default-export component body (from `export default function`
// up to the next top-level `function` declaration or EOF), so presentational
// sub-components and helper hooks living in the same file are not flagged.
// We scan the default-export component body (from `export default function` up
// to the next top-level `function` declaration or EOF), and the same span of a
// top-level `*PageDesktop`/`*PageMobile` — since ViewportRoute started picking
// the branch, the default export is a two-line shim and the real container is
// one of those two. Everything else at top level stays exempt: presentational
// sub-components and helper hooks live in these files on purpose.
// Context hooks like useTranslation/useParams are fine; the smell is stateful
// logic — useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef.
import { readdirSync, readFileSync } from 'node:fs'
@@ -13,23 +16,38 @@ import { fileURLToPath } from 'node:url'
const pagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'pages')
const BANNED = ['useState', 'useReducer', 'useEffect', 'useLayoutEffect', 'useMemo', 'useCallback', 'useRef']
const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*\\(`)
// `[<(]` rather than `\(`: a generic call — `useRef<() => void>(null)` — is the
// same thing, and it slipped through for as long as the pattern only knew `(`.
const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*[<(]`)
// Declarations the widened scan caught that are not this change's to move.
// Listed by the name they declare rather than by line, so an entry dies with
// its declaration instead of drifting onto whatever follows it. The rest of
// each listed file is still checked.
const KNOWN_ESCAPES = {
'TripPlannerPage.tsx': ['glMap', 'bookingExpense'],
}
const violations = []
for (const file of readdirSync(pagesDir)) {
if (!file.endsWith('Page.tsx') || file.endsWith('.test.tsx')) continue
const src = readFileSync(join(pagesDir, file), 'utf8')
const lines = src.split('\n')
const start = lines.findIndex(l => /export default function/.test(l))
if (start === -1) continue
// The page body ends at the next top-level declaration (a `function` at
// column 0) — everything after that is a sub-component or helper.
let end = lines.length
for (let i = start + 1; i < lines.length; i++) {
if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break }
}
for (let i = start; i < end; i++) {
if (bannedRe.test(lines[i])) {
const allowed = KNOWN_ESCAPES[file] ?? []
const starts = []
lines.forEach((line, i) => {
if (/export default function/.test(line) || /^function \w+Page(Desktop|Mobile)\b/.test(line)) starts.push(i)
})
for (const start of starts) {
// The page body ends at the next top-level declaration (a `function` at
// column 0) — everything after that is a sub-component or helper.
let end = lines.length
for (let i = start + 1; i < lines.length; i++) {
if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break }
}
for (let i = start; i < end; i++) {
if (!bannedRe.test(lines[i])) continue
if (allowed.some(name => new RegExp(`\\b${name}\\b`).test(lines[i]))) continue
violations.push(`${file}:${i + 1} ${lines[i].trim()}`)
}
}
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env node
/*
* theme:lint — guards the appearance token system.
*
* Flags styling that bypasses the design tokens and therefore won't follow a
* user's chosen scheme / transparency / text-size:
* - inline color literals (color: '#111', background: 'rgba(...)', boxShadow: '...rgba...')
* - inline numeric fontSize (fontSize: 13)
* - arbitrary-value Tailwind color classes (bg-[#..], text-[rgba(..)])
*
* ALLOWED (never flagged): var(--token) inline styles, bg-[var(--..)] classes,
* and genuinely dynamic values (data-driven colors, computed sizes/positions).
*
* Mirrors the i18n:parity gate. Default mode reports a baseline and exits 0;
* `--strict` exits non-zero when any violations remain (for once the backlog is
* burned down, or wired to changed files only). Add `theme-lint-disable` in a
* line comment to suppress an intentional exception (map/PDF/brand colors).
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
let SRC = new URL('../src', import.meta.url).pathname;
if (process.platform === 'win32' && SRC.startsWith('/')) SRC = SRC.slice(1);
// Surfaces where CSS variables genuinely cannot reach (injected map HTML, WebGL
// paint, standalone PDF documents) — colors there must stay literal.
const EXEMPT = [
/Mapbox/i, /placePopup/i, /marker/i, /popup/i, /TripPDF/, /JourneyBookPDF/,
/MapViewGL/, /MapView\./, /JourneyMapGL/, /reservationsMapbox/, /useAtlas/,
/ReservationOverlay/, /\.test\./, /\.spec\./,
];
const ARB_CLASS = /\b(?:bg|text|border|ring|fill|stroke|from|via|to|shadow|outline|decoration|divide|caret)-\[\s*(?:#|rgba?\(|hsla?\(|oklch\()/;
const INLINE_COLOR = /(?:color|background|backgroundColor|borderColor|border|borderTop|borderBottom|borderLeft|borderRight|boxShadow|fill|stroke|outline|textDecorationColor)\s*:\s*['"`]?\s*(?:#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(|oklch\()/;
const INLINE_FONTSIZE = /fontSize\s*:\s*['"`]?\d/;
function walk(dir, files = []) {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) walk(p, files);
else if (/\.(ts|tsx)$/.test(name)) files.push(p);
}
return files;
}
const strict = process.argv.includes('--strict');
const offenders = [];
let total = 0;
for (const f of walk(SRC)) {
if (EXEMPT.some((re) => re.test(f))) continue;
let count = 0;
for (const line of readFileSync(f, 'utf8').split('\n')) {
if (line.includes('theme-lint-disable')) continue;
if (ARB_CLASS.test(line) || INLINE_COLOR.test(line) || INLINE_FONTSIZE.test(line)) count++;
}
if (count) {
offenders.push([relative(SRC, f).replace(/\\/g, '/'), count]);
total += count;
}
}
offenders.sort((a, b) => b[1] - a[1]);
console.log(`theme:lint — ${total} hardcoded-style hits across ${offenders.length} files (map/PDF excluded).`);
for (const [f, c] of offenders.slice(0, 20)) console.log(` ${String(c).padStart(4)} ${f}`);
if (offenders.length > 20) console.log(` … and ${offenders.length - 20} more files.`);
console.log('\nNew/changed code must use tokens (bg-surface / text-content / bg-accent / var(--..)) and the');
console.log('text-title/subtitle/body/caption tiers — never inline #hex, never bg-[#..]. See src/theme/README.md.');
if (strict && total > 0) {
console.error(`\n✖ theme:lint:strict — ${total} violations remain.`);
process.exit(1);
}
+260 -4
View File
@@ -1,13 +1,15 @@
import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { render, screen, waitFor, act } from '@testing-library/react'
import { MemoryRouter, useLocation } from 'react-router'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../tests/helpers/msw/server'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import { resetAllStores } from '../tests/helpers/store'
import { buildUser, buildSettings } from '../tests/helpers/factories'
import { buildUser, buildSettings, buildTrip } from '../tests/helpers/factories'
import { offlineDb } from './db/offlineDb'
import { SETTINGS_WAIT_MS } from './utils/startDestination'
import App from './App'
// ── Mock page components ───────────────────────────────────────────────────────
@@ -29,10 +31,19 @@ vi.mock('./hooks/useInAppNotificationListener.ts', () => ({
// ── Helpers ────────────────────────────────────────────────────────────────────
/** Reports the router's current location, so a test can assert where App sent it. */
function LocationProbe() {
const loc = useLocation()
return <span data-testid="loc">{loc.pathname + loc.search}</span>
}
const currentPath = () => screen.getByTestId('loc').textContent
function renderApp(initialPath = '/') {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<App />
<LocationProbe />
</MemoryRouter>
)
}
@@ -81,6 +92,105 @@ describe('RootRedirect', () => {
})
})
// ── RootRedirect — startup destination ────────────────────────────────────────
describe('RootRedirect — startup destination', () => {
/** Serves GET /api/trips/active and reports whether it was asked at all. */
function stubActiveTrip(trip: { id: number; title: string } | null) {
const calls: string[] = []
server.use(
http.get('/api/trips/active', ({ request }) => {
calls.push(request.url)
return HttpResponse.json({ trip })
}),
)
return calls
}
it('FE-COMP-APP-026: opens the active trip on the chosen tab', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({
isLoaded: true,
settings: buildSettings({ start_page: 'active_trip', start_trip_tab: 'finanzplan' }),
})
stubActiveTrip({ id: 42, title: 'Japan' })
renderApp('/')
await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument())
})
it('FE-COMP-APP-027: falls back to the dashboard when the user has no trip', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({
isLoaded: true,
settings: buildSettings({ start_page: 'active_trip', start_trip_tab: 'finanzplan' }),
})
stubActiveTrip(null)
renderApp('/')
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
})
it('FE-COMP-APP-028: falls back to the dashboard when the lookup fails', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({
isLoaded: true,
settings: buildSettings({ start_page: 'active_trip' }),
})
server.use(http.get('/api/trips/active', () => HttpResponse.error()))
renderApp('/')
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
})
it('FE-COMP-APP-028b: opens the cached active trip when the launch is offline', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({
isLoaded: true,
settings: buildSettings({ start_page: 'active_trip' }),
})
const today = new Date()
const iso = (d: Date) => d.toISOString().slice(0, 10)
await offlineDb.trips.put(buildTrip({ id: 42, title: 'Japan', start_date: iso(today), end_date: iso(today) }))
const onLine = Object.getOwnPropertyDescriptor(Navigator.prototype, 'onLine')
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
// navigator and the Dexie table are shared with every other test in the
// file, so a failed assertion must not leave the rest of them offline.
try {
renderApp('/')
await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument())
} finally {
if (onLine) Object.defineProperty(Navigator.prototype, 'onLine', onLine)
delete (navigator as unknown as { onLine?: boolean }).onLine
await offlineDb.trips.clear()
}
})
// The whole point of the localStorage mirror: the default launch must not pay
// for a lookup it doesn't need.
it('FE-COMP-APP-029: never asks for the active trip when starting on the dashboard', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ isLoaded: true, settings: buildSettings({ start_page: 'dashboard' }) })
const calls = stubActiveTrip({ id: 42, title: 'Japan' })
renderApp('/')
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
expect(calls).toHaveLength(0)
})
it('FE-COMP-APP-030: reads the preference from localStorage before settings have loaded', async () => {
localStorage.setItem('trek_start_page', 'active_trip')
localStorage.setItem('trek_start_trip_tab', 'finanzplan')
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ isLoaded: false })
stubActiveTrip({ id: 42, title: 'Japan' })
renderApp('/')
await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument())
})
})
// ── ProtectedRoute — unauthenticated ──────────────────────────────────────────
describe('ProtectedRoute — unauthenticated', () => {
@@ -90,6 +200,22 @@ describe('ProtectedRoute — unauthenticated', () => {
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
})
// A session that ran out should return you to the page you were on. Pressing
// "log out" should not: clearing isAuthenticated re-renders this route for the
// page still on screen, and the ?redirect= it stamped there beat the user's
// startup destination on every login after the first.
it('FE-COMP-APP-034: keeps the return ticket when the session merely ended', async () => {
seedAuth({ isAuthenticated: false, loggingOut: false })
renderApp('/trips/1/files')
await waitFor(() => expect(currentPath()).toBe('/login?redirect=%2Ftrips%2F1%2Ffiles'))
})
it('FE-COMP-APP-035: drops it on a deliberate sign-out, so the startup destination decides', async () => {
seedAuth({ isAuthenticated: false, loggingOut: true })
renderApp('/dashboard')
await waitFor(() => expect(currentPath()).toBe('/login'))
})
it('FE-COMP-APP-005: /trips/42 redirects to /login when not authenticated', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/trips/42')
@@ -169,6 +295,8 @@ describe('ProtectedRoute — admin role check', () => {
// ── Public routes ──────────────────────────────────────────────────────────────
describe('Public routes', () => {
// Synchronous on purpose: LoginPage is the one page still statically imported,
// so this also holds the line against someone making it lazy later.
it('FE-COMP-APP-012: /login is accessible without authentication', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/login')
@@ -178,7 +306,7 @@ describe('Public routes', () => {
it('FE-COMP-APP-013: /shared/:token is accessible without authentication', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/shared/sometoken')
expect(screen.getByText('SharedTrip')).toBeInTheDocument()
expect(await screen.findByText('SharedTrip')).toBeInTheDocument()
})
it('FE-COMP-APP-014: unknown routes redirect to / which then redirects to /login', async () => {
@@ -188,6 +316,57 @@ describe('Public routes', () => {
})
})
// ── PublicRoute — redirect already-authenticated visitors ─────────────────────
describe('PublicRoute — already authenticated', () => {
/**
* The bounce goes to START_DESTINATION_ROUTE, so RootRedirect resolves where
* it actually lands — which needs settings, exactly like the cases above.
*/
function seedAuthedOnDashboard() {
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({
isLoaded: true,
settings: buildSettings({ start_page: 'dashboard' }),
})
}
it('FE-COMP-APP-012a: /login sends an authenticated visitor to the start destination', async () => {
seedAuthedOnDashboard()
renderApp('/login')
await waitFor(() => expect(currentPath()).toBe('/dashboard'))
expect(screen.queryByText('Login')).not.toBeInTheDocument()
})
it('FE-COMP-APP-012b: /register does the same', async () => {
seedAuthedOnDashboard()
renderApp('/register')
await waitFor(() => expect(currentPath()).toBe('/dashboard'))
expect(screen.queryByText('Login')).not.toBeInTheDocument()
})
it('FE-COMP-APP-012c: /login with a ?redirect= target is left to useLogin (OAuth consent handoff), not bounced', async () => {
// The consent page parks its URL in ?redirect= when it needs a login; that
// flow must reach the form even for an authenticated visitor, so the guard
// stays out of the way whenever a redirect target is present.
seedAuthedOnDashboard()
const target = '/login?redirect=' + encodeURIComponent('/oauth/consent?client_id=x')
renderApp(target)
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
expect(currentPath()).toBe(target)
})
it('FE-COMP-APP-012d: /register?invite= reaches the form so the token can be validated', async () => {
// An invite link is the one way someone joins a trip they were invited to
// from an account they are already signed into; bouncing it would swallow
// the token silently.
seedAuthedOnDashboard()
renderApp('/register?invite=abc123')
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
expect(currentPath()).toBe('/register?invite=abc123')
})
})
// ── App — on-mount effects ─────────────────────────────────────────────────────
describe('App — on-mount effects', () => {
@@ -233,6 +412,30 @@ describe('App — on-mount effects', () => {
await waitFor(() => expect(setDemoMode).toHaveBeenCalledWith(true))
})
it('FE-COMP-APP-018b: setManaged mirrors the config flag, and defaults to false when it is absent', async () => {
server.use(
http.get('/api/auth/app-config', () => HttpResponse.json({ managed: true }))
)
const setManaged = vi.fn()
useAuthStore.setState({
isLoading: false,
isAuthenticated: false,
loadUser: vi.fn().mockResolvedValue(undefined),
setManaged,
})
renderApp('/')
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(true))
// An older server that does not send the field must not leave the flag
// stuck on from a previous install in the same browser profile.
setManaged.mockClear()
server.use(
http.get('/api/auth/app-config', () => HttpResponse.json({}))
)
renderApp('/')
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(false))
})
it('FE-COMP-APP-019: loadSettings is called once the user is authenticated', async () => {
const loadSettings = vi.fn().mockResolvedValue(undefined)
seedAuth({ isAuthenticated: true, user: buildUser() })
@@ -320,3 +523,56 @@ describe('Version cache-busting', () => {
await waitFor(() => expect(reload).toHaveBeenCalled())
})
})
// Regression: a device that has never mirrored the preference must not treat
// "nothing mirrored" as "user wants the dashboard".
describe('RootRedirect — preference not mirrored on this device', () => {
it('FE-COMP-APP-031: honours the server setting when localStorage is empty', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
// Cold start: settings are still in flight when RootRedirect first runs.
useSettingsStore.setState({ isLoaded: false })
server.use(http.get('/api/trips/active', () => HttpResponse.json({ trip: { id: 42, title: 'Japan' } })))
renderApp('/')
// ...and land a moment later, exactly as loadSettings() does.
await act(async () => {
useSettingsStore.setState({
isLoaded: true,
settings: buildSettings({ start_page: 'active_trip', start_trip_tab: 'finanzplan' }),
})
})
await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument())
})
// Waiting for someone else's effect to fetch the settings made the decision
// ride on where that request landed in the queue — behind ~380 others on a
// cold start, which is how the redirect silently lost the race and fell
// through to the dashboard. It now asks for them itself.
it('FE-COMP-APP-033: asks for the settings itself instead of waiting to be told', async () => {
const loadSettings = vi.fn().mockResolvedValue(undefined)
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ isLoaded: false, loadSettings })
renderApp('/')
await waitFor(() => expect(loadSettings).toHaveBeenCalled())
})
// loadSettings leaves isLoaded false on a failed request so it can retry, so
// the wait above needs a floor or a launch with no backend never resolves.
it('FE-COMP-APP-032: gives up on the settings after the timeout and opens the dashboard', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ isLoaded: false })
renderApp('/')
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument()
await act(async () => { vi.advanceTimersByTime(SETTINGS_WAIT_MS + 100) })
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
vi.useRealTimers()
})
})
+390 -141
View File
@@ -1,28 +1,27 @@
import React, { useEffect, ReactNode } from 'react'
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import React, { useEffect, useState, useRef, ReactNode, Suspense } from 'react'
import { Routes, Route, Navigate, useLocation } from 'react-router'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import { applyAppearance } from './theme/applyAppearance'
import { useAddonStore } from './store/addonStore'
import { usePluginStore } from './store/pluginStore'
// The one page that stays in the entry chunk. Anyone logged out lands here, and
// every other route redirects here first — a chunk round trip in front of the login
// form would slow down the single screen that has to be there immediately.
import LoginPage from './pages/LoginPage'
import ForgotPasswordPage from './pages/ForgotPasswordPage'
import ResetPasswordPage from './pages/ResetPasswordPage'
import DashboardPage from './pages/DashboardPage'
import TripPlannerPage from './pages/TripPlannerPage'
import FilesPage from './pages/FilesPage'
import AdminPage from './pages/AdminPage'
import SettingsPage from './pages/SettingsPage'
import VacayPage from './pages/VacayPage'
import AtlasPage from './pages/AtlasPage'
import JourneyPage from './pages/JourneyPage'
import JourneyDetailPage from './pages/JourneyDetailPage'
import JourneyPublicPage from './pages/JourneyPublicPage'
import SharedTripPage from './pages/SharedTripPage'
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
import { ToastContainer } from './components/shared/Toast'
import BottomNav from './components/Layout/BottomNav'
import SaveToCollectionModal from './components/Collections/SaveToCollectionModal'
import MSaveToCollectionSheet from './components/Collections/MSaveToCollectionSheet'
import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget'
import MobileShell from './mobile/MobileShell'
import MRouteFallback from './mobile/components/MRouteFallback'
import ErrorBoundary from './components/shared/ErrorBoundary'
import { lazyWithRetry } from './utils/lazyWithRetry'
import { useIsPhone } from './mobile/useIsPhone'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi } from './api/client'
import { tripRepo } from './repo/tripRepo'
import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination'
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
@@ -30,6 +29,50 @@ import OfflineBanner from './components/Layout/OfflineBanner'
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
// Notice action registrations (side-effect imports):
import './pages/Trips/noticeActions.js'
import { managedRoutes } from './managed'
// Every page below loads on demand. The entry chunk used to carry all twenty of
// them eagerly, so opening /dashboard also paid for the planner, the journal, the
// atlas and the vacation planner. lazyWithRetry rather than lazy: a chunk that
// fails once gets a second, cache-busted attempt before the route boundary reaches
// for a reload.
const PluginPage = lazyWithRetry(() => import('./pages/PluginPage'))
const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage'))
const ResetPasswordPage = lazyWithRetry(() => import('./pages/ResetPasswordPage'))
const DashboardPage = lazyWithRetry(() => import('./pages/DashboardPage'))
const TripPlannerPage = lazyWithRetry(() => import('./pages/TripPlannerPage'))
const FilesPage = lazyWithRetry(() => import('./pages/FilesPage'))
const AdminPage = lazyWithRetry(() => import('./pages/AdminPage'))
const SettingsPage = lazyWithRetry(() => import('./pages/SettingsPage'))
const VacayPage = lazyWithRetry(() => import('./pages/VacayPage'))
const HelpPage = lazyWithRetry(() => import('./pages/HelpPage'))
const AtlasPage = lazyWithRetry(() => import('./pages/AtlasPage'))
const JourneyPage = lazyWithRetry(() => import('./pages/JourneyPage'))
const JourneyDetailPage = lazyWithRetry(() => import('./pages/JourneyDetailPage'))
const JourneyStudioPage = lazyWithRetry(() => import('./pages/JourneyStudioPage'))
const CollectionsPage = lazyWithRetry(() => import('./pages/CollectionsPage'))
const JourneyPublicPage = lazyWithRetry(() => import('./pages/JourneyPublicPage'))
const SharedTripPage = lazyWithRetry(() => import('./pages/SharedTripPage'))
const JoinTripPage = lazyWithRetry(() => import('./pages/JoinTripPage'))
const InAppNotificationsPage = lazyWithRetry(() => import('./pages/InAppNotificationsPage.tsx'))
const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePage'))
// The ten phone screens are chunks of their own, alongside the desktop pages
// rather than inside them. Each page used to import its M screen statically and
// decide while rendering, so the route chunk always carried both trees and the
// viewport only picked which half stayed dark. Here the branch decides the
// chunk: a phone never loads the desktop planner, a desktop never the mobile
// shell.
const MDashboardScreen = lazyWithRetry(() => import('./mobile/screens/dashboard/MDashboard'))
const MTripScreen = lazyWithRetry(() => import('./mobile/screens/trip/MTripShell'))
const MAdminScreen = lazyWithRetry(() => import('./mobile/screens/admin/MAdmin'))
const MSettingsScreen = lazyWithRetry(() => import('./mobile/screens/settings/MSettings'))
const MVacayScreen = lazyWithRetry(() => import('./mobile/screens/vacay/MVacay'))
const MAtlasScreen = lazyWithRetry(() => import('./mobile/screens/atlas/MAtlas'))
const MJourneyScreen = lazyWithRetry(() => import('./mobile/screens/journey/MJourney'))
const MJourneyDetailScreen = lazyWithRetry(() => import('./mobile/screens/journey/MJourneyDetail'))
const MCollectionsScreen = lazyWithRetry(() => import('./mobile/screens/collections/MCollections'))
const MNotificationsScreen = lazyWithRetry(() => import('./mobile/screens/notifications/MNotifications'))
interface ProtectedRouteProps {
children: ReactNode
@@ -42,9 +85,11 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
const user = useAuthStore((s) => s.user)
const isLoading = useAuthStore((s) => s.isLoading)
const appRequireMfa = useAuthStore((s) => s.appRequireMfa)
const loggingOut = useAuthStore((s) => s.loggingOut)
const addonStore = useAddonStore()
const { t } = useTranslation()
const location = useLocation()
const isPhone = useIsPhone()
if (isLoading) {
return (
@@ -58,6 +103,10 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
}
if (!isAuthenticated) {
// A session that ended on its own should come back to where it left off; a
// deliberate sign-out is a fresh start and gets no return ticket, so the
// startup destination decides where the next login lands.
if (loggingOut) return <Navigate to="/login" replace state={{ noRedirect: true }} />
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash)
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
}
@@ -79,18 +128,132 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
return <Navigate to="/dashboard" replace />
}
// Below the md breakpoint the new mobile shell owns chrome (tokens, dock,
// sheets, toasts); from 768px up the legacy wrapper stays untouched. The
// shell branches internally so pages keep their state when the viewport
// crosses the breakpoint.
// The boundary sits inside the shell so a broken page keeps the navigation the
// user needs to leave it — outside, the route would be a dead end, and the
// mobile --m-* tokens live on the shell's .m-root anyway.
//
// key, not resetKeys: ProtectedRoute is the same component at the same position
// for all 16 protected routes, so without it React could keep the failed
// instance across a navigation and the error would follow the user around.
return (
<div className="flex flex-col h-screen md:block md:h-auto">
<div className="flex-1 overflow-y-auto md:overflow-visible">{children}</div>
<BottomNav />
</div>
<MobileShell isPhone={isPhone}>
<ErrorBoundary
key={location.pathname}
boundaryId="route"
level="route"
variant={isPhone ? 'mobile' : 'desktop'}
>
{children}
</ErrorBoundary>
</MobileShell>
)
}
/**
* The public routes render outside ProtectedRoute, so the route boundary above
* never sees them — including /login, where someone lands when everything else
* failed, and the two anonymous share pages.
*/
function PublicRoute({ children, redirectAuthed = false }: { children: React.ReactNode; redirectAuthed?: boolean }) {
const location = useLocation()
// redirectAuthed (only /login and /register) bounces a visitor who is already
// authenticated when they land here — manual URL, browser back button (#1810).
// Only on a bare URL, because every parameter this page takes is a flow that
// has to reach useLogin intact: ?redirect= carries the OAuth consent handoff
// (bouncing drops it, or loops if the server still reports login_required),
// ?invite= puts the form into register mode against a validated token, and
// ?oidc_code= / ?oidc_error= complete an external login. A visitor with any of
// those in the URL asked for this page on purpose.
// Capture the flag at mount so a fresh login is left alone: it flips
// isAuthenticated to true while the takeoff animation still plays here, before
// useLogin navigates away.
// The target is START_DESTINATION_ROUTE, not /dashboard, so the bounce honours
// the start-page preference the same way useLogin does.
const wasAuthenticated = useRef(useAuthStore.getState().isAuthenticated)
if (redirectAuthed && wasAuthenticated.current && !location.search) {
return <Navigate to={START_DESTINATION_ROUTE} replace />
}
return (
<ErrorBoundary key={location.pathname} boundaryId="public-route" level="route">
{children}
</ErrorBoundary>
)
}
/**
* Picks the chunk, not just the branch. Both sides come through lazyWithRetry,
* so exactly one of them is fetched for a given viewport — which is the whole
* point: the ternary that used to sit in each page only ran once the browser
* had already paid for both trees.
*/
function ViewportRoute({ phone: Phone, desktop: Desktop }: {
phone: React.ComponentType
desktop: React.ComponentType
}): React.ReactElement {
const isPhone = useIsPhone()
return isPhone ? <Phone /> : <Desktop />
}
/**
* The entry point every shortcut, bookmark and the installed PWA share
* (manifest start_url is '/'), which is why the startup destination is decided
* here rather than on /dashboard — landing on the dashboard directly has to keep
* working, or there would be no way back to it.
*
* Once this device has seen the preference it comes from the localStorage
* mirror, so the common launch waits for nothing. A device that has never seen
* it waits for the settings instead of assuming the default — the preference
* lives on the server, and guessing here would ignore it on every browser that
* didn't set it. Only 'active_trip' costs the one lookup that finds the trip,
* and any failure along the way lands on the dashboard.
*/
function RootRedirect() {
const { isAuthenticated, isLoading } = useAuthStore()
const settingsLoaded = useSettingsStore((s) => s.isLoaded)
const settings = useSettingsStore((s) => s.settings)
const loadSettings = useSettingsStore((s) => s.loadSettings)
const [target, setTarget] = useState<string | null>(null)
// Bounds the wait below: loadSettings deliberately leaves isLoaded false on a
// failed request so it can retry, which would otherwise strand a launch that
// started offline on the spinner.
const [settingsGaveUp, setSettingsGaveUp] = useState(false)
if (isLoading) {
useEffect(() => {
if (settingsLoaded) return
const timer = setTimeout(() => setSettingsGaveUp(true), SETTINGS_WAIT_MS)
return () => clearTimeout(timer)
}, [settingsLoaded])
useEffect(() => {
if (isLoading || !isAuthenticated || target) return
const mirrored = readStartDestination()
if (!mirrored && !settingsLoaded && !settingsGaveUp) {
// Ask for the settings instead of waiting for whoever else might. The
// store de-dupes concurrent loads, so this is free when one is already in
// flight — and it stops the decision from riding on where that request
// happens to land in the queue behind everything else a launch fires off.
loadSettings()
return
}
// Loaded settings are the truth; the mirror is what we knew last time.
const { page, tab } = settingsLoaded
? { page: settings.start_page ?? DEFAULT_START_PAGE, tab: settings.start_trip_tab ?? DEFAULT_START_TRIP_TAB }
: (mirrored ?? { page: DEFAULT_START_PAGE, tab: DEFAULT_START_TRIP_TAB })
if (page !== 'active_trip') { setTarget('/dashboard'); return }
let cancelled = false
// Through the repo, not the api: offline this answers from Dexie instead of
// bouncing a cached trip to the dashboard.
tripRepo.active()
.then(({ trip }) => { if (!cancelled) setTarget(trip ? tripStartPath(trip.id, tab) : '/dashboard') })
.catch(() => { if (!cancelled) setTarget('/dashboard') })
return () => { cancelled = true }
}, [isLoading, isAuthenticated, settingsLoaded, settingsGaveUp, settings, target])
if (isLoading || (isAuthenticated && !target)) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div>
@@ -98,13 +261,32 @@ function RootRedirect() {
)
}
return <Navigate to={isAuthenticated ? '/dashboard' : '/login'} replace />
return <Navigate to={isAuthenticated ? (target ?? '/dashboard') : '/login'} replace />
}
/**
* Shown while a route chunk is in flight. Same geometry as the auth spinner above,
* but on theme tokens — that one predates the styleguide and a new surface does not
* get to inherit its raw slate.
*/
function RouteFallback() {
// The fallback renders above MobileShell, so it has to pick its own palette —
// on a phone the desktop spinner on bg-surface would be a foreign white sheet
// in front of the mobile screen.
const isPhone = useIsPhone()
if (isPhone) return <MRouteFallback />
return (
<div className="min-h-screen flex items-center justify-center bg-surface">
<div className="w-10 h-10 border-4 border-edge border-t-content rounded-full animate-spin"></div>
</div>
)
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
const { loadPlugins } = usePluginStore()
useEffect(() => {
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
@@ -118,7 +300,8 @@ export default function App() {
loadUser()
}
}
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
setManaged(!!config?.managed)
setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true)
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
@@ -130,6 +313,7 @@ export default function App() {
if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled)
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled)
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
if (config?.version) {
@@ -162,6 +346,7 @@ export default function App() {
if (isAuthenticated) {
loadSettings()
loadAddons()
loadPlugins()
}
}, [isAuthenticated])
@@ -174,31 +359,23 @@ export default function App() {
const isSharedPage = location.pathname.startsWith('/shared/')
useEffect(() => {
// Shared page always forces light mode
if (isSharedPage) {
document.documentElement.classList.remove('dark')
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', '#ffffff')
return
}
const mode = settings.dark_mode
const applyDark = (isDark: boolean) => {
document.documentElement.classList.toggle('dark', isDark)
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', isDark ? '#09090b' : '#ffffff')
}
if (mode === 'auto') {
const run = () =>
applyAppearance({
darkMode: settings.dark_mode,
appearance: settings.appearance,
isSharedPage,
})
run()
// Re-resolve on OS theme change while in auto mode.
if (!isSharedPage && settings.dark_mode === 'auto') {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
applyDark(mq.matches)
const handler = (e: MediaQueryListEvent) => applyDark(e.matches)
const handler = () => run()
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}
applyDark(mode === true || mode === 'dark')
}, [settings.dark_mode, isSharedPage])
}, [settings.dark_mode, settings.appearance, isSharedPage])
const isPhone = useIsPhone()
const isAuthPage = location.pathname.startsWith('/login')
|| location.pathname.startsWith('/register')
|| location.pathname.startsWith('/forgot-password')
@@ -206,101 +383,173 @@ export default function App() {
return (
<TranslationProvider>
{!isAuthPage && <SystemNoticeHost />}
<ToastContainer />
<OfflineBanner />
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/shared/:token" element={<SharedTripPage />} />
<Route path="/public/journey/:token" element={<JourneyPublicPage />} />
<Route path="/register" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
<Route path="/oauth/consent" element={<OAuthAuthorizePage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/trips/:id"
element={
<ProtectedRoute>
<TripPlannerPage />
</ProtectedRoute>
}
/>
<Route
path="/trips/:id/files"
element={
<ProtectedRoute>
<FilesPage />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<ProtectedRoute adminRequired>
<AdminPage />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<SettingsPage />
</ProtectedRoute>
}
/>
<Route
path="/vacay"
element={
<ProtectedRoute>
<VacayPage />
</ProtectedRoute>
}
/>
<Route
path="/atlas"
element={
<ProtectedRoute>
<AtlasPage />
</ProtectedRoute>
}
/>
<Route
path="/journey"
element={
<ProtectedRoute addonId="journey">
<JourneyPage />
</ProtectedRoute>
}
/>
<Route
path="/journey/:id"
element={
<ProtectedRoute addonId="journey">
<JourneyDetailPage />
</ProtectedRoute>
}
/>
<Route
path="/notifications"
element={
<ProtectedRoute>
<InAppNotificationsPage />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
{!isAuthPage && <ErrorBoundary boundaryId="widget:system-notice" fallback={null}><SystemNoticeHost /></ErrorBoundary>}
<ErrorBoundary boundaryId="widget:toast" fallback={null}><ToastContainer /></ErrorBoundary>
{!isAuthPage && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
{!isAuthPage && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
{/* One boundary for all route chunks, above <Routes> so it stays mounted
across navigations. react-router runs location updates inside a transition,
so a mounted boundary keeps the current page on screen instead of flashing
a spinner on every jump — the spinner is for the first paint of a deep link. */}
<Suspense fallback={<RouteFallback />}>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<PublicRoute redirectAuthed><LoginPage /></PublicRoute>} />
<Route path="/shared/:token" element={<PublicRoute><SharedTripPage /></PublicRoute>} />
<Route path="/public/journey/:token" element={<PublicRoute><JourneyPublicPage /></PublicRoute>} />
<Route path="/register" element={<PublicRoute redirectAuthed><LoginPage /></PublicRoute>} />
<Route path="/forgot-password" element={<PublicRoute><ForgotPasswordPage /></PublicRoute>} />
<Route path="/reset-password" element={<PublicRoute><ResetPasswordPage /></PublicRoute>} />
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
<Route path="/oauth/consent" element={<PublicRoute><OAuthAuthorizePage /></PublicRoute>} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<ViewportRoute phone={MDashboardScreen} desktop={DashboardPage} />
</ProtectedRoute>
}
/>
{/* Trip invite link (#1143) — behind ProtectedRoute so an anonymous
visitor is redirected to /login (never registration) and returns here. */}
<Route
path="/join/:token"
element={
<ProtectedRoute>
<JoinTripPage />
</ProtectedRoute>
}
/>
<Route
path="/help"
element={
<ProtectedRoute>
<HelpPage />
</ProtectedRoute>
}
/>
<Route
path="/help/:slug"
element={
<ProtectedRoute>
<HelpPage />
</ProtectedRoute>
}
/>
<Route
path="/trips/:id"
element={
<ProtectedRoute>
<ViewportRoute phone={MTripScreen} desktop={TripPlannerPage} />
</ProtectedRoute>
}
/>
<Route
path="/trips/:id/files"
element={
<ProtectedRoute>
<FilesPage />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<ProtectedRoute adminRequired>
<ViewportRoute phone={MAdminScreen} desktop={AdminPage} />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<ViewportRoute phone={MSettingsScreen} desktop={SettingsPage} />
</ProtectedRoute>
}
/>
<Route
path="/plugins/:pluginId"
element={
<ProtectedRoute>
<PluginPage />
</ProtectedRoute>
}
/>
{/* Empty in this repository, and read unconditionally so the public
build walks the same path as any other. See client/src/managed. */}
{managedRoutes.map(r => (
<Route
key={r.path}
path={r.path}
element={<ProtectedRoute adminRequired={r.adminOnly}>{r.element}</ProtectedRoute>}
/>
))}
<Route
path="/vacay"
element={
<ProtectedRoute>
<ViewportRoute phone={MVacayScreen} desktop={VacayPage} />
</ProtectedRoute>
}
/>
<Route
path="/atlas"
element={
<ProtectedRoute>
<ViewportRoute phone={MAtlasScreen} desktop={AtlasPage} />
</ProtectedRoute>
}
/>
<Route
path="/journey"
element={
<ProtectedRoute addonId="journey">
<ViewportRoute phone={MJourneyScreen} desktop={JourneyPage} />
</ProtectedRoute>
}
/>
<Route
path="/journey/:id"
element={
<ProtectedRoute addonId="journey">
<ViewportRoute phone={MJourneyDetailScreen} desktop={JourneyDetailPage} />
</ProtectedRoute>
}
>
{/* Studio is nested so the journey stays mounted underneath it and
shows through the panel's margin — it is opened on top of the
journey, not navigated away to. */}
<Route path="studio" element={<JourneyStudioPage />} />
</Route>
<Route
path="/collections"
element={
<ProtectedRoute addonId="collections">
<ViewportRoute phone={MCollectionsScreen} desktop={CollectionsPage} />
</ProtectedRoute>
}
/>
<Route
path="/collections/:id"
element={
<ProtectedRoute addonId="collections">
<ViewportRoute phone={MCollectionsScreen} desktop={CollectionsPage} />
</ProtectedRoute>
}
/>
<Route
path="/notifications"
element={
<ProtectedRoute>
<ViewportRoute phone={MNotificationsScreen} desktop={InAppNotificationsPage} />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</TranslationProvider>
)
}
+111
View File
@@ -0,0 +1,111 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useAuthStore } from './store/authStore'
import { useAddonStore } from './store/addonStore'
import { resetAllStores } from '../tests/helpers/store'
import { buildUser } from '../tests/helpers/factories'
import App from './App'
/**
* The viewport decides which chunk gets fetched, so the switch lives in App.tsx
* and no longer in the pages. This is that contract, for all ten routes in one
* place — it used to be five scattered spot checks, one per page test.
*
* A stub per screen keeps the test about one thing: below the breakpoint the M
* root hangs off the route, and the desktop page is never rendered at all.
*/
const isPhone = vi.hoisted(() => ({ value: false }))
vi.mock('./mobile/useIsPhone', () => ({ useIsPhone: () => isPhone.value }))
vi.mock('./mobile/screens/dashboard/MDashboard', () => ({ default: () => <div>m-dashboard</div> }))
vi.mock('./mobile/screens/trip/MTripShell', () => ({ default: () => <div>m-trip</div> }))
vi.mock('./mobile/screens/admin/MAdmin', () => ({ default: () => <div>m-admin</div> }))
vi.mock('./mobile/screens/settings/MSettings', () => ({ default: () => <div>m-settings</div> }))
vi.mock('./mobile/screens/vacay/MVacay', () => ({ default: () => <div>m-vacay</div> }))
vi.mock('./mobile/screens/atlas/MAtlas', () => ({ default: () => <div>m-atlas</div> }))
vi.mock('./mobile/screens/journey/MJourney', () => ({ default: () => <div>m-journey</div> }))
vi.mock('./mobile/screens/journey/MJourneyDetail', () => ({ default: () => <div>m-journey-detail</div> }))
vi.mock('./mobile/screens/collections/MCollections', () => ({ default: () => <div>m-collections</div> }))
vi.mock('./mobile/screens/notifications/MNotifications', () => ({ default: () => <div>m-notifications</div> }))
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>d-dashboard</div> }))
vi.mock('./pages/TripPlannerPage', () => ({ default: () => <div>d-trip</div> }))
vi.mock('./pages/AdminPage', () => ({ default: () => <div>d-admin</div> }))
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>d-settings</div> }))
vi.mock('./pages/VacayPage', () => ({ default: () => <div>d-vacay</div> }))
vi.mock('./pages/AtlasPage', () => ({ default: () => <div>d-atlas</div> }))
vi.mock('./pages/JourneyPage', () => ({ default: () => <div>d-journey</div> }))
vi.mock('./pages/JourneyDetailPage', () => ({ default: () => <div>d-journey-detail</div> }))
vi.mock('./pages/CollectionsPage', () => ({ default: () => <div>d-collections</div> }))
vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () => <div>d-notifications</div> }))
// The notification listener opens a WebSocket on mount.
vi.mock('./hooks/useInAppNotificationListener.ts', () => ({
useInAppNotificationListener: vi.fn(),
}))
/** path, mobile marker, desktop marker */
const ROUTES: [string, string, string][] = [
['/dashboard', 'm-dashboard', 'd-dashboard'],
['/trips/1', 'm-trip', 'd-trip'],
['/admin', 'm-admin', 'd-admin'],
['/settings', 'm-settings', 'd-settings'],
['/vacay', 'm-vacay', 'd-vacay'],
['/atlas', 'm-atlas', 'd-atlas'],
['/journey', 'm-journey', 'd-journey'],
['/journey/1', 'm-journey-detail', 'd-journey-detail'],
['/collections', 'm-collections', 'd-collections'],
['/collections/1', 'm-collections', 'd-collections'],
['/notifications', 'm-notifications', 'd-notifications'],
]
function renderAt(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<App />
</MemoryRouter>
)
}
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
isPhone.value = false
// An admin passes the role check on /admin; every other route ignores it.
useAuthStore.setState({
isLoading: false,
isAuthenticated: true,
user: buildUser({ role: 'admin', mfa_enabled: true }),
appRequireMfa: false,
loadUser: vi.fn().mockResolvedValue(undefined),
})
// /journey and /collections sit behind addons; without this they redirect to
// the dashboard and the route under test never renders.
useAddonStore.setState({ loaded: true, isEnabled: () => true })
})
describe('App — viewport routing', () => {
it.each(ROUTES)(
'FE-COMP-APPVP-001: %s renders the mobile screen below the breakpoint',
async (path, mobile, desktop) => {
isPhone.value = true
renderAt(path)
expect(await screen.findByText(mobile)).toBeInTheDocument()
expect(screen.queryByText(desktop)).not.toBeInTheDocument()
}
)
it.each(ROUTES)(
'FE-COMP-APPVP-002: %s renders the desktop page above the breakpoint',
async (path, mobile, desktop) => {
renderAt(path)
expect(await screen.findByText(desktop)).toBeInTheDocument()
expect(screen.queryByText(mobile)).not.toBeInTheDocument()
}
)
})
+511
View File
@@ -0,0 +1,511 @@
// FE-APIWIRE-001 to FE-APIWIRE-036
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { AxiosError, type AxiosAdapter, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import { http, HttpResponse } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import { weatherResultSchema } from '@trek/shared'
// client.ts probes the health endpoint to tell an edge-proxy auth wall apart
// from a plain offline boot — the probe result decides whether it tears down
// the service worker, so the tests drive it directly.
const { probeNow } = vi.hoisted(() => ({
probeNow: vi.fn(async (): Promise<'online' | 'offline' | 'proxy-wall'> => 'offline'),
}))
vi.mock('../sync/connectivity', () => ({ probeNow }))
const { apiClient, adminApi, mapsApi, pluginsApi, parseInDev } = await import('./client')
interface FakeLocation {
href: string
origin: string
pathname: string
search: string
hash: string
reload: () => void
}
let reload: ReturnType<typeof vi.fn<() => void>>
function setLocation(pathname: string, search = '', hash = ''): FakeLocation {
reload = vi.fn<() => void>()
const loc: FakeLocation = {
href: `http://localhost:3000${pathname}${search}${hash}`,
origin: 'http://localhost:3000',
pathname,
search,
hash,
reload,
}
Object.defineProperty(window, 'location', { writable: true, configurable: true, value: loc })
return loc
}
const realLocation = window.location
/** Records the outgoing config and answers 200 without touching the network. */
function okAdapter(sink: InternalAxiosRequestConfig[]): AxiosAdapter {
return (config) => {
sink.push(config)
return Promise.resolve({
data: { ok: true }, status: 200, statusText: 'OK', headers: {}, config,
} as AxiosResponse)
}
}
/** Rejects the way a CORS/offline failure does: an error with no `response`. */
const networkErrorAdapter: AxiosAdapter = (config) =>
Promise.reject(new AxiosError('Network Error', AxiosError.ERR_NETWORK, config))
async function captureError(run: () => Promise<unknown>): Promise<AxiosError> {
const err = await run().then(() => null, (e: unknown) => e as AxiosError)
expect(err, 'expected the request to reject').not.toBeNull()
return err as AxiosError
}
beforeEach(() => {
probeNow.mockResolvedValue('offline')
setLocation('/dashboard')
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
Object.defineProperty(window, 'location', { writable: true, configurable: true, value: realLocation })
delete (navigator as { serviceWorker?: unknown }).serviceWorker
})
describe('client > request interceptor', () => {
it('FE-APIWIRE-001: mutating requests get an idempotency key, reads do not', async () => {
const sink: InternalAxiosRequestConfig[] = []
const adapter = okAdapter(sink)
await apiClient.get('/probe', { adapter })
await apiClient.post('/probe', {}, { adapter })
await apiClient.put('/probe', {}, { adapter })
await apiClient.patch('/probe', {}, { adapter })
await apiClient.delete('/probe', { adapter })
const keys = sink.map(c => c.headers['X-Idempotency-Key'])
expect(keys[0]).toBeUndefined()
for (const key of keys.slice(1)) expect(typeof key).toBe('string')
})
it('FE-APIWIRE-002: each write gets its own key so retries can be deduplicated', async () => {
const sink: InternalAxiosRequestConfig[] = []
const adapter = okAdapter(sink)
await apiClient.post('/probe', {}, { adapter })
await apiClient.post('/probe', {}, { adapter })
expect(sink[0].headers['X-Idempotency-Key']).not.toBe(sink[1].headers['X-Idempotency-Key'])
})
it('FE-APIWIRE-003: a pre-generated key from the mutation queue is left alone', async () => {
const sink: InternalAxiosRequestConfig[] = []
await apiClient.post('/probe', {}, {
adapter: okAdapter(sink),
headers: { 'X-Idempotency-Key': 'queued-key' },
})
expect(sink[0].headers['X-Idempotency-Key']).toBe('queued-key')
})
it('FE-APIWIRE-004: falls back to getRandomValues when crypto.randomUUID is missing', async () => {
const realCrypto = globalThis.crypto
vi.stubGlobal('crypto', {
getRandomValues: realCrypto.getRandomValues.bind(realCrypto),
} as unknown as Crypto)
const sink: InternalAxiosRequestConfig[] = []
await apiClient.post('/probe', {}, { adapter: okAdapter(sink) })
// randomUUID needs a secure context, so on the plain-http installs the
// README documents this branch is what actually runs. getRandomValues has no
// such requirement, so the fallback is still a full v4 UUID.
expect(String(sink[0].headers['X-Idempotency-Key'])).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
)
})
it('FE-APIWIRE-004b: the last-resort key is never degenerate, even with no Web Crypto at all', async () => {
vi.stubGlobal('crypto', undefined)
const keys = new Set<string>()
for (let i = 0; i < 50; i++) {
const sink: InternalAxiosRequestConfig[] = []
await apiClient.post('/probe', {}, { adapter: okAdapter(sink) })
keys.add(String(sink[0].headers['X-Idempotency-Key']))
}
// The old fallback was Math.random().toString(36).slice(2), which is not
// length-stable: 0.5 yields a single character and 0 yields the empty string.
// An empty key makes the server skip deduplication entirely, so a retried
// write applies twice.
for (const k of keys) expect(k.length).toBeGreaterThan(16)
expect(keys.size).toBe(50)
})
it('FE-APIWIRE-005: the socket id header is omitted while no socket is connected', async () => {
const sink: InternalAxiosRequestConfig[] = []
await apiClient.get('/probe', { adapter: okAdapter(sink) })
expect(sink[0].headers['X-Socket-Id']).toBeUndefined()
})
it('FE-APIWIRE-034: a rejection from an earlier request interceptor is passed on untouched', async () => {
const boom = new Error('interceptor refused the request')
const id = apiClient.interceptors.request.use(() => Promise.reject(boom))
const sink: InternalAxiosRequestConfig[] = []
try {
await expect(apiClient.post('/probe', {}, { adapter: okAdapter(sink) })).rejects.toBe(boom)
} finally {
apiClient.interceptors.request.eject(id)
}
expect(sink).toHaveLength(0)
})
})
describe('client > rate-limit translation', () => {
beforeEach(() => {
server.use(http.get('/api/limited', () => HttpResponse.json({ error: 'Too Many Requests' }, { status: 429 })))
})
it('FE-APIWIRE-006: a 429 is rewritten in the stored app language', async () => {
localStorage.setItem('app_language', 'de')
const err = await captureError(() => apiClient.get('/limited'))
expect(err.message).toBe('Zu viele Versuche. Bitte versuchen Sie es später erneut.')
expect((err.response?.data as { error: string }).error)
.toBe('Zu viele Versuche. Bitte versuchen Sie es später erneut.')
})
it('FE-APIWIRE-007: an unsupported language falls back to English', async () => {
localStorage.setItem('app_language', 'kl')
const err = await captureError(() => apiClient.get('/limited'))
expect(err.message).toBe('Too many attempts. Please try again later.')
})
it('FE-APIWIRE-008: no stored language falls back to English', async () => {
const err = await captureError(() => apiClient.get('/limited'))
expect(err.message).toBe('Too many attempts. Please try again later.')
})
it('FE-APIWIRE-009: a blocked localStorage still yields the English message', async () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('storage disabled')
})
const err = await captureError(() => apiClient.get('/limited'))
expect(err.message).toBe('Too many attempts. Please try again later.')
})
it('FE-APIWIRE-010: a non-object 429 body is replaced with the translated error object', async () => {
server.use(http.get('/api/limited', () => new HttpResponse('slow down', { status: 429 })))
const err = await captureError(() => apiClient.get('/limited'))
expect(err.response?.data).toEqual({ error: 'Too many attempts. Please try again later.' })
})
it('FE-APIWIRE-035: an array 429 body is replaced, not grafted onto', async () => {
server.use(http.get('/api/limited', () => HttpResponse.json([{ field: 'email' }], { status: 429 })))
const err = await captureError(() => apiClient.get('/limited'))
expect(err.response?.data).toEqual({ error: 'Too many attempts. Please try again later.' })
})
it('FE-APIWIRE-036: Catalan, Greek and Vietnamese have their own 429 message', async () => {
for (const lang of ['ca', 'gr', 'vi']) {
localStorage.setItem('app_language', lang)
const err = await captureError(() => apiClient.get('/limited'))
expect(err.message).not.toBe('Too many attempts. Please try again later.')
}
})
})
describe('client > proxy auth challenges', () => {
function installServiceWorker(unregister: () => Promise<boolean>) {
const getRegistration = vi.fn(async () => ({ unregister }))
Object.defineProperty(navigator, 'serviceWorker', {
writable: true, configurable: true, value: { getRegistration },
})
return getRegistration
}
it('FE-APIWIRE-011: an HTML 401 unregisters the service worker and reloads', async () => {
const unregister = vi.fn(async () => true)
installServiceWorker(unregister)
server.use(http.get('/api/auth/me', () =>
new HttpResponse('<html>login</html>', { status: 401, headers: { 'Content-Type': 'text/html' } })))
await captureError(() => apiClient.get('/auth/me'))
expect(unregister).toHaveBeenCalled()
expect(reload).toHaveBeenCalledTimes(1)
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBe('1')
})
it('FE-APIWIRE-012: the reauth reload only fires once per session', async () => {
installServiceWorker(vi.fn(async () => true))
sessionStorage.setItem('proxy_reauth_attempted', '1')
server.use(http.get('/api/auth/me', () =>
new HttpResponse('<html>login</html>', { status: 401, headers: { 'Content-Type': 'text/html' } })))
await captureError(() => apiClient.get('/auth/me'))
expect(reload).not.toHaveBeenCalled()
})
it('FE-APIWIRE-013: an HTML 401 on a public path never reloads', async () => {
setLocation('/login')
server.use(http.get('/api/auth/me', () =>
new HttpResponse('<html>login</html>', { status: 401, headers: { 'Content-Type': 'text/html' } })))
await captureError(() => apiClient.get('/auth/me'))
expect(reload).not.toHaveBeenCalled()
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull()
})
it('FE-APIWIRE-014: a response-less failure that probes proxy-wall reloads', async () => {
probeNow.mockResolvedValue('proxy-wall')
installServiceWorker(vi.fn(async () => true))
await captureError(() => apiClient.get('/auth/me', { adapter: networkErrorAdapter }))
expect(probeNow).toHaveBeenCalled()
expect(reload).toHaveBeenCalledTimes(1)
})
it('FE-APIWIRE-015: a response-less failure that probes offline keeps the SW (#1346)', async () => {
probeNow.mockResolvedValue('offline')
const getRegistration = installServiceWorker(vi.fn(async () => true))
await captureError(() => apiClient.get('/auth/me', { adapter: networkErrorAdapter }))
expect(getRegistration).not.toHaveBeenCalled()
expect(reload).not.toHaveBeenCalled()
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull()
})
it('FE-APIWIRE-016: a failing unregister still reloads into the proxy challenge', async () => {
probeNow.mockResolvedValue('proxy-wall')
Object.defineProperty(navigator, 'serviceWorker', {
writable: true, configurable: true,
value: { getRegistration: vi.fn(async () => { throw new Error('SW gone') }) },
})
await captureError(() => apiClient.get('/auth/me', { adapter: networkErrorAdapter }))
expect(reload).toHaveBeenCalledTimes(1)
})
it('FE-APIWIRE-017: a proxy-wall probe on a shared page does not reload', async () => {
setLocation('/shared/tok123')
probeNow.mockResolvedValue('proxy-wall')
await captureError(() => apiClient.get('/auth/me', { adapter: networkErrorAdapter }))
expect(reload).not.toHaveBeenCalled()
})
it('FE-APIWIRE-035: a 401 without a content-type is not mistaken for a proxy login page', async () => {
installServiceWorker(vi.fn(async () => true))
server.use(http.get('/api/auth/me', () => new HttpResponse(null, { status: 401 })))
await captureError(() => apiClient.get('/auth/me'))
expect(reload).not.toHaveBeenCalled()
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull()
})
it('FE-APIWIRE-018: a successful response clears the reauth marker', async () => {
sessionStorage.setItem('proxy_reauth_attempted', '1')
server.use(http.get('/api/auth/me', () => HttpResponse.json({ ok: true })))
await apiClient.get('/auth/me')
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull()
})
})
describe('client > redirect handling', () => {
it('FE-APIWIRE-019: a JSON AUTH_REQUIRED 401 redirects with the full current path', async () => {
const loc = setLocation('/trips/7', '?tab=map', '#day-2')
server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 })))
await captureError(() => apiClient.get('/auth/me'))
expect(loc.href).toBe('/login?redirect=' + encodeURIComponent('/trips/7?tab=map#day-2'))
})
it('FE-APIWIRE-020: an MFA_REQUIRED 403 sends the user to the settings page', async () => {
const loc = setLocation('/dashboard')
server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'MFA_REQUIRED' }, { status: 403 })))
await captureError(() => apiClient.get('/auth/me'))
expect(loc.href).toBe('/settings?mfa=required')
})
})
describe('client > dev-only contract drift checks', () => {
it('FE-APIWIRE-021: parseInDev passes a matching payload straight through', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const payload = { temp: 21, main: 'Clear', description: 'clear sky', type: 'sun' }
expect(parseInDev(weatherResultSchema, payload, 'weather.get')).toBe(payload)
expect(warn).not.toHaveBeenCalled()
})
it('FE-APIWIRE-022: parseInDev warns but still returns a drifting payload', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const payload = { temp: 'warm', main: 'Clear', description: 'clear sky', type: 'sun' }
expect(parseInDev(weatherResultSchema, payload, 'weather.get')).toBe(payload)
expect(warn).toHaveBeenCalledWith(
'[api] weather.get: response did not match the @trek/shared schema',
expect.anything(),
)
})
it('FE-APIWIRE-023: a drifting maps response is reported under its own label', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
server.use(http.post('/api/maps/search', () => HttpResponse.json({ nonsense: true })))
await expect(mapsApi.search('Rome')).resolves.toEqual({ nonsense: true })
expect(warn).toHaveBeenCalledWith(
'[api] maps.search: response did not match the @trek/shared schema',
expect.anything(),
)
})
})
describe('client > pluginsApi.invoke namespace guard', () => {
it('FE-APIWIRE-024: a relative sub-path stays inside the plugin namespace', async () => {
let seen = ''
server.use(http.get('/api/plugins/koffi/ping', ({ request }) => {
seen = new URL(request.url).pathname
return HttpResponse.json({ pong: true })
}))
await expect(pluginsApi.invoke('koffi', '/ping')).resolves.toEqual({ pong: true })
expect(seen).toBe('/api/plugins/koffi/ping')
})
it('FE-APIWIRE-025: method, body and query string survive the rewrite', async () => {
let received: unknown
let query = ''
server.use(http.post('/api/plugins/koffi/sync', async ({ request }) => {
received = await request.json()
query = new URL(request.url).search
return HttpResponse.json({ ok: true })
}))
await pluginsApi.invoke('koffi', 'sync?full=1', { method: 'POST', body: { since: 5 } })
expect(received).toEqual({ since: 5 })
expect(query).toBe('?full=1')
})
it('FE-APIWIRE-026: traversal out of the plugin prefix is refused', async () => {
await expect(pluginsApi.invoke('koffi', '/../../auth/me'))
.rejects.toThrow('plugin route escapes its namespace')
})
it('FE-APIWIRE-027: an absolute off-origin target is refused', async () => {
await expect(pluginsApi.invoke('koffi', 'https://evil.test/steal'))
.rejects.toThrow('plugin route escapes its namespace')
})
it('FE-APIWIRE-028: an unparseable sub-path is refused before any request', async () => {
await expect(pluginsApi.invoke('koffi', 'http://')).rejects.toThrow('invalid plugin route')
})
})
describe('client > adminApi.llmLocalPull', () => {
function streamingResponse(chunks: string[]): Response {
let i = 0
const encoder = new TextEncoder()
return {
ok: true,
status: 200,
body: {
getReader: () => ({
read: async () => (i < chunks.length
? { done: false, value: encoder.encode(chunks[i++]) }
: { done: true, value: undefined }),
cancel: async () => {},
}),
},
} as unknown as Response
}
it('FE-APIWIRE-029: NDJSON progress lines are reported even when split across chunks', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(streamingResponse([
'{"status":"pulling","total":100,"completed":10}\n{"status":"pul',
'ling","total":100,"completed":90}\n{"status":"success"}\n',
]))
const onProgress = vi.fn((_p: { status?: string }) => {})
await adminApi.llmLocalPull('http://ollama:11434', 'qwen3:8b', onProgress)
expect(onProgress.mock.calls.map(c => c[0])).toEqual([
{ status: 'pulling', total: 100, completed: 10 },
{ status: 'pulling', total: 100, completed: 90 },
{ status: 'success' },
])
})
it('FE-APIWIRE-030: blank and half-written lines are skipped instead of throwing', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(streamingResponse([
'\n \n{"status":"a"}\nnot-json\n{"status":"b"}\n',
]))
const onProgress = vi.fn((_p: { status?: string }) => {})
await adminApi.llmLocalPull('http://ollama:11434', 'qwen3:8b', onProgress)
expect(onProgress.mock.calls.map(c => c[0])).toEqual([{ status: 'a' }, { status: 'b' }])
})
it('FE-APIWIRE-031: a JSON error body becomes the thrown message', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
ok: false, status: 502, body: null,
json: async () => ({ error: 'ollama unreachable' }),
} as unknown as Response)
await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', vi.fn()))
.rejects.toThrow('ollama unreachable')
})
it('FE-APIWIRE-032: a non-JSON error body falls back to the status code', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
ok: false, status: 500, body: null,
json: async () => { throw new SyntaxError('not json') },
} as unknown as Response)
await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', vi.fn()))
.rejects.toThrow('Pull failed (500)')
})
it('FE-APIWIRE-036: a throw from onProgress aborts the pull', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(streamingResponse([
'{"status":"pulling manifest"}\n{"error":"manifest not found"}\n{"status":"success"}\n',
]))
const onProgress = vi.fn((p: { error?: string }) => {
if (p.error) throw new Error(p.error)
})
await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', onProgress))
.rejects.toThrow('manifest not found')
expect(onProgress).toHaveBeenCalledTimes(2)
})
it('FE-APIWIRE-033: a 200 without a readable body reports the missing stream', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
ok: true, status: 200, body: null,
json: async () => ({}),
} as unknown as Response)
await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', vi.fn()))
.rejects.toThrow('Pull returned no progress stream')
})
})
+865
View File
@@ -0,0 +1,865 @@
// FE-APISURF-001 to FE-APISURF-054
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { AxiosResponse } from 'axios'
import { http, HttpResponse } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import {
apiClient,
authApi, oauthApi, tripsApi, daysApi, placesApi, assignmentsApi, packingApi, todoApi,
tagsApi, categoriesApi, adminApi, addonsApi, pluginsApi, airtrailApi, journeyApi,
mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi,
configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi,
shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi, memoriesApi,
} from './client'
interface Recorded { method: string; url: string; body: unknown }
let log: Recorded[] = []
/** One record per outgoing request: verb, path+query and (parsed) JSON body. */
function recorder() {
return http.all(/\/api\//, async ({ request }) => {
const url = new URL(request.url)
const raw = await request.text()
let body: unknown
if (raw) {
try { body = JSON.parse(raw) } catch { body = raw }
}
log.push({ method: request.method, url: url.pathname + url.search, body })
return HttpResponse.json({ ok: true })
})
}
beforeEach(() => {
log = []
server.use(recorder())
// parseInDev/checkInDev warn on every stub payload that doesn't match its
// @trek/shared schema — expected here, so keep the output readable.
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
interface Call { n: string; r: () => Promise<unknown>; e: string }
/** Runs every call in isolation and checks the verb + path it produced. */
async function assertCalls(calls: Call[]): Promise<void> {
for (const c of calls) {
log = []
await c.r()
expect(log.length, `${c.n}: expected exactly one request`).toBe(1)
const rec = log[0]
const [path] = rec.url.split('?')
expect(`${rec.method} ${path}`, c.n).toBe(c.e)
}
}
/** Runs one call and returns the request it produced. */
async function traceOne(run: () => Promise<unknown>): Promise<Recorded> {
log = []
await run()
expect(log).toHaveLength(1)
return log[0]
}
describe('client > endpoint wiring', () => {
it('FE-APISURF-001: authApi maps every method to its auth endpoint', async () => {
await assertCalls([
{ n: 'register', r: () => authApi.register({ email: 'a@b.c', password: 'pw' }), e: 'POST /api/auth/register' },
{ n: 'validateInvite', r: () => authApi.validateInvite('inv-tok'), e: 'GET /api/auth/invite/inv-tok' },
{ n: 'login', r: () => authApi.login({ email: 'a@b.c', password: 'pw' }), e: 'POST /api/auth/login' },
{ n: 'verifyMfaLogin', r: () => authApi.verifyMfaLogin({ mfa_token: 'm', code: '123456' }), e: 'POST /api/auth/mfa/verify-login' },
{ n: 'mfaSetup', r: () => authApi.mfaSetup(), e: 'POST /api/auth/mfa/setup' },
{ n: 'mfaEnable', r: () => authApi.mfaEnable({ code: '123456' }), e: 'POST /api/auth/mfa/enable' },
{ n: 'mfaDisable', r: () => authApi.mfaDisable({ password: 'pw', code: '123456' }), e: 'POST /api/auth/mfa/disable' },
{ n: 'me', r: () => authApi.me(), e: 'GET /api/auth/me' },
{ n: 'updateMapsKey', r: () => authApi.updateMapsKey('gkey'), e: 'PUT /api/auth/me/maps-key' },
{ n: 'updateApiKeys', r: () => authApi.updateApiKeys({ google_maps: null }), e: 'PUT /api/auth/me/api-keys' },
{ n: 'updateSettings', r: () => authApi.updateSettings({ theme: 'dark' }), e: 'PUT /api/auth/me/settings' },
{ n: 'getSettings', r: () => authApi.getSettings(), e: 'GET /api/auth/me/settings' },
{ n: 'listUsers', r: () => authApi.listUsers(), e: 'GET /api/auth/users' },
{ n: 'deleteAvatar', r: () => authApi.deleteAvatar(), e: 'DELETE /api/auth/avatar' },
{ n: 'getAppConfig', r: () => authApi.getAppConfig(), e: 'GET /api/auth/app-config' },
{ n: 'updateAppSettings', r: () => authApi.updateAppSettings({ registration_enabled: true }), e: 'PUT /api/auth/app-settings' },
{ n: 'validateKeys', r: () => authApi.validateKeys(), e: 'GET /api/auth/validate-keys' },
{ n: 'travelStats', r: () => authApi.travelStats(), e: 'GET /api/auth/travel-stats' },
{ n: 'changePassword', r: () => authApi.changePassword({ current_password: 'a', new_password: 'b' }), e: 'PUT /api/auth/me/password' },
{ n: 'forgotPassword', r: () => authApi.forgotPassword({ email: 'a@b.c' }), e: 'POST /api/auth/forgot-password' },
{ n: 'resetPassword', r: () => authApi.resetPassword({ token: 't', new_password: 'b' }), e: 'POST /api/auth/reset-password' },
{ n: 'deleteOwnAccount', r: () => authApi.deleteOwnAccount(), e: 'DELETE /api/auth/me' },
{ n: 'demoLogin', r: () => authApi.demoLogin(), e: 'POST /api/auth/demo-login' },
{ n: 'mcpTokens.list', r: () => authApi.mcpTokens.list(), e: 'GET /api/auth/mcp-tokens' },
{ n: 'mcpTokens.create', r: () => authApi.mcpTokens.create('cli'), e: 'POST /api/auth/mcp-tokens' },
{ n: 'mcpTokens.delete', r: () => authApi.mcpTokens.delete(7), e: 'DELETE /api/auth/mcp-tokens/7' },
{ n: 'passkey.registerOptions', r: () => authApi.passkey.registerOptions('pw'), e: 'POST /api/auth/passkey/register/options' },
{ n: 'passkey.registerVerify', r: () => authApi.passkey.registerVerify({ id: 'cred' }, 'Yubikey'), e: 'POST /api/auth/passkey/register/verify' },
{ n: 'passkey.loginOptions', r: () => authApi.passkey.loginOptions(), e: 'POST /api/auth/passkey/login/options' },
{ n: 'passkey.loginVerify', r: () => authApi.passkey.loginVerify({ id: 'cred' }), e: 'POST /api/auth/passkey/login/verify' },
{ n: 'passkey.list', r: () => authApi.passkey.list(), e: 'GET /api/auth/passkey/credentials' },
{ n: 'passkey.rename', r: () => authApi.passkey.rename(3, 'Phone'), e: 'PATCH /api/auth/passkey/credentials/3' },
{ n: 'passkey.delete', r: () => authApi.passkey.delete(3, 'pw'), e: 'DELETE /api/auth/passkey/credentials/3' },
])
})
it('FE-APISURF-002: oauthApi maps consent + client/session management endpoints', async () => {
const params = {
response_type: 'code', client_id: 'cid', redirect_uri: 'https://app/cb',
scope: 'trips:read', code_challenge: 'chal', code_challenge_method: 'S256',
}
await assertCalls([
{ n: 'validate', r: () => oauthApi.validate(params), e: 'GET /api/oauth/authorize/validate' },
{ n: 'authorize', r: () => oauthApi.authorize({ ...params, approved: true }), e: 'POST /api/oauth/authorize' },
{ n: 'clients.list', r: () => oauthApi.clients.list(), e: 'GET /api/oauth/clients' },
{ n: 'clients.create', r: () => oauthApi.clients.create({ name: 'App', allowed_scopes: ['trips:read'] }), e: 'POST /api/oauth/clients' },
{ n: 'clients.rotate', r: () => oauthApi.clients.rotate('cid'), e: 'POST /api/oauth/clients/cid/rotate' },
{ n: 'clients.delete', r: () => oauthApi.clients.delete('cid'), e: 'DELETE /api/oauth/clients/cid' },
{ n: 'sessions.list', r: () => oauthApi.sessions.list(), e: 'GET /api/oauth/sessions' },
{ n: 'sessions.revoke', r: () => oauthApi.sessions.revoke(4), e: 'DELETE /api/oauth/sessions/4' },
])
})
it('FE-APISURF-003: tripsApi maps trip, member and guest endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => tripsApi.list(), e: 'GET /api/trips' },
{ n: 'create', r: () => tripsApi.create({ title: 'Rome' }), e: 'POST /api/trips' },
{ n: 'get', r: () => tripsApi.get(3), e: 'GET /api/trips/3' },
{ n: 'update', r: () => tripsApi.update(3, { title: 'Rome 2' }), e: 'PUT /api/trips/3' },
{ n: 'delete', r: () => tripsApi.delete(3), e: 'DELETE /api/trips/3' },
{ n: 'searchCoverImages', r: () => tripsApi.searchCoverImages('rome'), e: 'GET /api/trips/cover-images/search' },
{ n: 'archive', r: () => tripsApi.archive(3), e: 'PUT /api/trips/3' },
{ n: 'unarchive', r: () => tripsApi.unarchive(3), e: 'PUT /api/trips/3' },
{ n: 'getMembers', r: () => tripsApi.getMembers(3), e: 'GET /api/trips/3/members' },
{ n: 'addMember', r: () => tripsApi.addMember(3, 'bob'), e: 'POST /api/trips/3/members' },
{ n: 'removeMember', r: () => tripsApi.removeMember(3, 9), e: 'DELETE /api/trips/3/members/9' },
{ n: 'transferOwnership', r: () => tripsApi.transferOwnership(3, 9), e: 'POST /api/trips/3/transfer' },
{ n: 'createGuest', r: () => tripsApi.createGuest(3, 'Anna'), e: 'POST /api/trips/3/guests' },
{ n: 'renameGuest', r: () => tripsApi.renameGuest(3, 9, 'Ana'), e: 'PUT /api/trips/3/guests/9' },
{ n: 'deleteGuest', r: () => tripsApi.deleteGuest(3, 9), e: 'DELETE /api/trips/3/guests/9' },
{ n: 'copy', r: () => tripsApi.copy(3, { title: 'Copy' }), e: 'POST /api/trips/3/copy' },
{ n: 'bundle', r: () => tripsApi.bundle(3), e: 'GET /api/trips/3/bundle' },
])
})
it('FE-APISURF-004: daysApi and dayNotesApi map their nested trip endpoints', async () => {
await assertCalls([
{ n: 'days.list', r: () => daysApi.list(1), e: 'GET /api/trips/1/days' },
{ n: 'days.create', r: () => daysApi.create(1, { date: '2026-06-01' }), e: 'POST /api/trips/1/days' },
{ n: 'days.update', r: () => daysApi.update(1, 2, { notes: 'hi' }), e: 'PUT /api/trips/1/days/2' },
{ n: 'days.updateTransport', r: () => daysApi.updateTransport(1, 2, 'car'), e: 'PUT /api/trips/1/days/2/transport' },
{ n: 'days.delete', r: () => daysApi.delete(1, 2), e: 'DELETE /api/trips/1/days/2' },
{ n: 'days.reorder', r: () => daysApi.reorder(1, [2, 1]), e: 'PUT /api/trips/1/days/reorder' },
{ n: 'dayNotes.list', r: () => dayNotesApi.list(1, 2), e: 'GET /api/trips/1/days/2/notes' },
{ n: 'dayNotes.create', r: () => dayNotesApi.create(1, 2, { text: 'note' }), e: 'POST /api/trips/1/days/2/notes' },
{ n: 'dayNotes.update', r: () => dayNotesApi.update(1, 2, 5, { text: 'edit' }), e: 'PUT /api/trips/1/days/2/notes/5' },
{ n: 'dayNotes.delete', r: () => dayNotesApi.delete(1, 2, 5), e: 'DELETE /api/trips/1/days/2/notes/5' },
])
})
it('FE-APISURF-005: placesApi maps CRUD, rating and list-import endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => placesApi.list(1), e: 'GET /api/trips/1/places' },
{ n: 'create', r: () => placesApi.create(1, { name: 'Colosseum' }), e: 'POST /api/trips/1/places' },
{ n: 'get', r: () => placesApi.get(1, 5), e: 'GET /api/trips/1/places/5' },
{ n: 'update', r: () => placesApi.update(1, 5, { name: 'Forum' }), e: 'PUT /api/trips/1/places/5' },
{ n: 'delete', r: () => placesApi.delete(1, 5), e: 'DELETE /api/trips/1/places/5' },
{ n: 'searchImage', r: () => placesApi.searchImage(1, 5), e: 'GET /api/trips/1/places/5/image' },
{ n: 'importGoogleList', r: () => placesApi.importGoogleList(1, 'https://maps.app/x'), e: 'POST /api/trips/1/places/import/google-list' },
{ n: 'importNaverList', r: () => placesApi.importNaverList(1, 'https://naver/x'), e: 'POST /api/trips/1/places/import/naver-list' },
{ n: 'bulkDelete', r: () => placesApi.bulkDelete(1, [5, 6]), e: 'POST /api/trips/1/places/bulk-delete' },
{ n: 'bulkUpdate', r: () => placesApi.bulkUpdate(1, [5], { category_id: 2 }), e: 'POST /api/trips/1/places/bulk-update' },
])
})
it('FE-APISURF-006: assignmentsApi maps day-plan endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => assignmentsApi.list(1, 2), e: 'GET /api/trips/1/days/2/assignments' },
{ n: 'create', r: () => assignmentsApi.create(1, 2, { place_id: 5 }), e: 'POST /api/trips/1/days/2/assignments' },
{ n: 'delete', r: () => assignmentsApi.delete(1, 2, 7), e: 'DELETE /api/trips/1/days/2/assignments/7' },
{ n: 'reorder', r: () => assignmentsApi.reorder(1, 2, [7, 8]), e: 'PUT /api/trips/1/days/2/assignments/reorder' },
{ n: 'move', r: () => assignmentsApi.move(1, 7, 3, 0), e: 'PUT /api/trips/1/assignments/7/move' },
{ n: 'update', r: () => assignmentsApi.update(1, 2, 7, { notes: 'x' }), e: 'PUT /api/trips/1/days/2/assignments/7' },
{ n: 'getParticipants', r: () => assignmentsApi.getParticipants(1, 7), e: 'GET /api/trips/1/assignments/7/participants' },
{ n: 'setParticipants', r: () => assignmentsApi.setParticipants(1, 7, [4]), e: 'PUT /api/trips/1/assignments/7/participants' },
{ n: 'updateTime', r: () => assignmentsApi.updateTime(1, 7, { place_time: '09:00' }), e: 'PUT /api/trips/1/assignments/7/time' },
{ n: 'updateTransport', r: () => assignmentsApi.updateTransport(1, 7, null), e: 'PUT /api/trips/1/assignments/7/transport' },
])
})
it('FE-APISURF-007: packingApi maps item, bag and template endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => packingApi.list(1), e: 'GET /api/trips/1/packing' },
{ n: 'create', r: () => packingApi.create(1, { name: 'Towel' }), e: 'POST /api/trips/1/packing' },
{ n: 'bulkImport', r: () => packingApi.bulkImport(1, [{ name: 'Socks' }]), e: 'POST /api/trips/1/packing/import' },
{ n: 'update', r: () => packingApi.update(1, 4, { checked: true }), e: 'PUT /api/trips/1/packing/4' },
{ n: 'delete', r: () => packingApi.delete(1, 4), e: 'DELETE /api/trips/1/packing/4' },
{ n: 'reorder', r: () => packingApi.reorder(1, [4, 5]), e: 'PUT /api/trips/1/packing/reorder' },
{ n: 'setSharing', r: () => packingApi.setSharing(1, 4, { visibility: 'shared' }), e: 'PUT /api/trips/1/packing/4/sharing' },
{ n: 'clone', r: () => packingApi.clone(1, 4), e: 'POST /api/trips/1/packing/4/clone' },
{ n: 'addContributor', r: () => packingApi.addContributor(1, 4), e: 'POST /api/trips/1/packing/4/contributors' },
{ n: 'removeContributor', r: () => packingApi.removeContributor(1, 4, 9), e: 'DELETE /api/trips/1/packing/4/contributors/9' },
{ n: 'getCategoryAssignees', r: () => packingApi.getCategoryAssignees(1), e: 'GET /api/trips/1/packing/category-assignees' },
{ n: 'listTemplates', r: () => packingApi.listTemplates(1), e: 'GET /api/trips/1/packing/templates' },
{ n: 'applyTemplate', r: () => packingApi.applyTemplate(1, 6), e: 'POST /api/trips/1/packing/apply-template/6' },
{ n: 'saveAsTemplate', r: () => packingApi.saveAsTemplate(1, 'Beach'), e: 'POST /api/trips/1/packing/save-as-template' },
{ n: 'setBagMembers', r: () => packingApi.setBagMembers(1, 2, [9]), e: 'PUT /api/trips/1/packing/bags/2/members' },
{ n: 'listBags', r: () => packingApi.listBags(1), e: 'GET /api/trips/1/packing/bags' },
{ n: 'createBag', r: () => packingApi.createBag(1, { name: 'Carry-on' }), e: 'POST /api/trips/1/packing/bags' },
{ n: 'updateBag', r: () => packingApi.updateBag(1, 2, { name: 'Hold' }), e: 'PUT /api/trips/1/packing/bags/2' },
{ n: 'deleteBag', r: () => packingApi.deleteBag(1, 2), e: 'DELETE /api/trips/1/packing/bags/2' },
])
})
it('FE-APISURF-008: todoApi maps todo endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => todoApi.list(1), e: 'GET /api/trips/1/todo' },
{ n: 'create', r: () => todoApi.create(1, { name: 'Book train' }), e: 'POST /api/trips/1/todo' },
{ n: 'update', r: () => todoApi.update(1, 3, { checked: true }), e: 'PUT /api/trips/1/todo/3' },
{ n: 'delete', r: () => todoApi.delete(1, 3), e: 'DELETE /api/trips/1/todo/3' },
{ n: 'reorder', r: () => todoApi.reorder(1, [3, 4]), e: 'PUT /api/trips/1/todo/reorder' },
{ n: 'getCategoryAssignees', r: () => todoApi.getCategoryAssignees(1), e: 'GET /api/trips/1/todo/category-assignees' },
])
})
it('FE-APISURF-009: tagsApi and categoriesApi map their global endpoints', async () => {
await assertCalls([
{ n: 'tags.list', r: () => tagsApi.list(), e: 'GET /api/tags' },
{ n: 'tags.create', r: () => tagsApi.create({ name: 'Food' }), e: 'POST /api/tags' },
{ n: 'tags.update', r: () => tagsApi.update(2, { name: 'Eat' }), e: 'PUT /api/tags/2' },
{ n: 'tags.delete', r: () => tagsApi.delete(2), e: 'DELETE /api/tags/2' },
{ n: 'categories.list', r: () => categoriesApi.list(), e: 'GET /api/categories' },
{ n: 'categories.create', r: () => categoriesApi.create({ name: 'Museum' }), e: 'POST /api/categories' },
{ n: 'categories.update', r: () => categoriesApi.update(2, { name: 'Art' }), e: 'PUT /api/categories/2' },
{ n: 'categories.delete', r: () => categoriesApi.delete(2), e: 'DELETE /api/categories/2' },
])
})
it('FE-APISURF-010: adminApi maps user, addon and settings endpoints', async () => {
await assertCalls([
{ n: 'users', r: () => adminApi.users(), e: 'GET /api/admin/users' },
{ n: 'createUser', r: () => adminApi.createUser({ email: 'a@b.c' }), e: 'POST /api/admin/users' },
{ n: 'updateUser', r: () => adminApi.updateUser(2, { role: 'admin' }), e: 'PUT /api/admin/users/2' },
{ n: 'deleteUser', r: () => adminApi.deleteUser(2), e: 'DELETE /api/admin/users/2' },
{ n: 'resetUserPasskeys', r: () => adminApi.resetUserPasskeys(2), e: 'DELETE /api/admin/users/2/passkeys' },
{ n: 'stats', r: () => adminApi.stats(), e: 'GET /api/admin/stats' },
{ n: 'saveDemoBaseline', r: () => adminApi.saveDemoBaseline(), e: 'POST /api/admin/save-demo-baseline' },
{ n: 'getOidc', r: () => adminApi.getOidc(), e: 'GET /api/admin/oidc' },
{ n: 'updateOidc', r: () => adminApi.updateOidc({ enabled: true }), e: 'PUT /api/admin/oidc' },
{ n: 'addons', r: () => adminApi.addons(), e: 'GET /api/admin/addons' },
{ n: 'updateAddon', r: () => adminApi.updateAddon(3, { enabled: false }), e: 'PUT /api/admin/addons/3' },
{ n: 'checkVersion', r: () => adminApi.checkVersion(), e: 'GET /api/admin/version-check' },
{ n: 'getBagTracking', r: () => adminApi.getBagTracking(), e: 'GET /api/admin/bag-tracking' },
{ n: 'updateBagTracking', r: () => adminApi.updateBagTracking(true), e: 'PUT /api/admin/bag-tracking' },
{ n: 'getPlacesPhotos', r: () => adminApi.getPlacesPhotos(), e: 'GET /api/admin/places-photos' },
{ n: 'updatePlacesPhotos', r: () => adminApi.updatePlacesPhotos(false), e: 'PUT /api/admin/places-photos' },
{ n: 'getPlacesAutocomplete', r: () => adminApi.getPlacesAutocomplete(), e: 'GET /api/admin/places-autocomplete' },
{ n: 'updatePlacesAutocomplete', r: () => adminApi.updatePlacesAutocomplete(true), e: 'PUT /api/admin/places-autocomplete' },
{ n: 'getPlacesDetails', r: () => adminApi.getPlacesDetails(), e: 'GET /api/admin/places-details' },
{ n: 'updatePlacesDetails', r: () => adminApi.updatePlacesDetails(true), e: 'PUT /api/admin/places-details' },
{ n: 'getCollabFeatures', r: () => adminApi.getCollabFeatures(), e: 'GET /api/admin/collab-features' },
{ n: 'updateCollabFeatures', r: () => adminApi.updateCollabFeatures({ polls: true }), e: 'PUT /api/admin/collab-features' },
{ n: 'getPermissions', r: () => adminApi.getPermissions(), e: 'GET /api/admin/permissions' },
{ n: 'updatePermissions', r: () => adminApi.updatePermissions({ edit_trip: 'member' }), e: 'PUT /api/admin/permissions' },
{ n: 'rotateJwtSecret', r: () => adminApi.rotateJwtSecret(), e: 'POST /api/admin/rotate-jwt-secret' },
{ n: 'sendTestNotification', r: () => adminApi.sendTestNotification({ channel: 'email' }), e: 'POST /api/admin/dev/test-notification' },
{ n: 'getNotificationPreferences', r: () => adminApi.getNotificationPreferences(), e: 'GET /api/admin/notification-preferences' },
{ n: 'updateNotificationPreferences', r: () => adminApi.updateNotificationPreferences({ email: { trip_invite: true } }), e: 'PUT /api/admin/notification-preferences' },
{ n: 'getDefaultUserSettings', r: () => adminApi.getDefaultUserSettings(), e: 'GET /api/admin/default-user-settings' },
{ n: 'updateDefaultUserSettings', r: () => adminApi.updateDefaultUserSettings({ language: 'de' }), e: 'PUT /api/admin/default-user-settings' },
{ n: 'getStorage', r: () => adminApi.getStorage(), e: 'GET /api/admin/storage' },
{ n: 'updateStorage', r: () => adminApi.updateStorage({ backends: [], categories: {}, version: 0 }), e: 'PUT /api/admin/storage' },
{ n: 'testStorageBackend', r: () => adminApi.testStorageBackend({ name: 'x', type: 'local', options: { root: '/data' } }), e: 'POST /api/admin/storage/test' },
{ n: 'startStorageBackfill', r: () => adminApi.startStorageBackfill('m'), e: 'POST /api/admin/storage/backends/m/backfill' },
{ n: 'cancelStorageBackfill', r: () => adminApi.cancelStorageBackfill('m'), e: 'DELETE /api/admin/storage/backends/m/backfill' },
{ n: 'startStorageMigration', r: () => adminApi.startStorageMigration('files', 'dest'), e: 'POST /api/admin/storage/migrations' },
{ n: 'cancelStorageMigration', r: () => adminApi.cancelStorageMigration('files'), e: 'DELETE /api/admin/storage/migrations/files' },
{ n: 'refreshStorageStats', r: () => adminApi.refreshStorageStats(), e: 'POST /api/admin/storage/stats/refresh' },
{ n: 'mcpTokens', r: () => adminApi.mcpTokens(), e: 'GET /api/admin/mcp-tokens' },
{ n: 'deleteMcpToken', r: () => adminApi.deleteMcpToken(4), e: 'DELETE /api/admin/mcp-tokens/4' },
{ n: 'oauthSessions', r: () => adminApi.oauthSessions(), e: 'GET /api/admin/oauth-sessions' },
{ n: 'revokeOAuthSession', r: () => adminApi.revokeOAuthSession(4), e: 'DELETE /api/admin/oauth-sessions/4' },
{ n: 'listInvites', r: () => adminApi.listInvites(), e: 'GET /api/admin/invites' },
{ n: 'listInviteTrips', r: () => adminApi.listInviteTrips(), e: 'GET /api/admin/invites/trips' },
{ n: 'createInvite', r: () => adminApi.createInvite({ max_uses: 3 }), e: 'POST /api/admin/invites' },
{ n: 'deleteInvite', r: () => adminApi.deleteInvite(8), e: 'DELETE /api/admin/invites/8' },
{ n: 'auditLog', r: () => adminApi.auditLog(), e: 'GET /api/admin/audit-log' },
])
})
it('FE-APISURF-011: adminApi maps the plugin management endpoints', async () => {
await assertCalls([
{ n: 'plugins', r: () => adminApi.plugins(), e: 'GET /api/admin/plugins' },
{ n: 'pluginBrowse', r: () => adminApi.pluginBrowse(), e: 'GET /api/admin/plugins/registry' },
{ n: 'pluginDetail', r: () => adminApi.pluginDetail('trek/koffi'), e: 'GET /api/admin/plugins/registry/trek%2Fkoffi' },
{ n: 'pluginInstall', r: () => adminApi.pluginInstall('koffi', { version: '1.0.0' }), e: 'POST /api/admin/plugins/install' },
{ n: 'pluginActivate', r: () => adminApi.pluginActivate('koffi'), e: 'POST /api/admin/plugins/koffi/activate' },
{ n: 'pluginDeactivate', r: () => adminApi.pluginDeactivate('koffi'), e: 'POST /api/admin/plugins/koffi/deactivate' },
{ n: 'pluginUpdate', r: () => adminApi.pluginUpdate('koffi'), e: 'POST /api/admin/plugins/koffi/update' },
{ n: 'pluginRetrust', r: () => adminApi.pluginRetrust('koffi', '2.0.0', 'PUBKEY'), e: 'POST /api/admin/plugins/koffi/retrust' },
{ n: 'pluginUninstall', r: () => adminApi.pluginUninstall('koffi', true), e: 'POST /api/admin/plugins/koffi/uninstall' },
{ n: 'pluginRescan', r: () => adminApi.pluginRescan(), e: 'POST /api/admin/plugins/rescan' },
{ n: 'pluginLink', r: () => adminApi.pluginLink('/srv/plugin'), e: 'POST /api/admin/plugins/link' },
{ n: 'pluginReload', r: () => adminApi.pluginReload('koffi'), e: 'POST /api/admin/plugins/koffi/reload' },
{ n: 'pluginEgressHosts', r: () => adminApi.pluginEgressHosts('koffi'), e: 'GET /api/admin/plugins/koffi/egress-hosts' },
{ n: 'pluginSetEgressHosts', r: () => adminApi.pluginSetEgressHosts('koffi', ['a.example']), e: 'PUT /api/admin/plugins/koffi/egress-hosts' },
{ n: 'pluginErrors', r: () => adminApi.pluginErrors('koffi'), e: 'GET /api/admin/plugins/koffi/errors' },
{ n: 'pluginAudit', r: () => adminApi.pluginAudit('koffi'), e: 'GET /api/admin/plugins/koffi/audit' },
{ n: 'llmLocalModels', r: () => adminApi.llmLocalModels('http://ollama:11434'), e: 'GET /api/admin/llm/local/models' },
])
})
it('FE-APISURF-012: adminApi maps the packing-template endpoints', async () => {
await assertCalls([
{ n: 'packingTemplates', r: () => adminApi.packingTemplates(), e: 'GET /api/admin/packing-templates' },
{ n: 'getPackingTemplate', r: () => adminApi.getPackingTemplate(1), e: 'GET /api/admin/packing-templates/1' },
{ n: 'createPackingTemplate', r: () => adminApi.createPackingTemplate({ name: 'Ski' }), e: 'POST /api/admin/packing-templates' },
{ n: 'updatePackingTemplate', r: () => adminApi.updatePackingTemplate(1, { name: 'Ski 2' }), e: 'PUT /api/admin/packing-templates/1' },
{ n: 'deletePackingTemplate', r: () => adminApi.deletePackingTemplate(1), e: 'DELETE /api/admin/packing-templates/1' },
{ n: 'addTemplateCategory', r: () => adminApi.addTemplateCategory(1, { name: 'Clothes' }), e: 'POST /api/admin/packing-templates/1/categories' },
{ n: 'updateTemplateCategory', r: () => adminApi.updateTemplateCategory(1, 2, { name: 'Wear' }), e: 'PUT /api/admin/packing-templates/1/categories/2' },
{ n: 'deleteTemplateCategory', r: () => adminApi.deleteTemplateCategory(1, 2), e: 'DELETE /api/admin/packing-templates/1/categories/2' },
{ n: 'addTemplateItem', r: () => adminApi.addTemplateItem(1, 2, { name: 'Gloves' }), e: 'POST /api/admin/packing-templates/1/categories/2/items' },
{ n: 'updateTemplateItem', r: () => adminApi.updateTemplateItem(1, 3, { name: 'Mittens' }), e: 'PUT /api/admin/packing-templates/1/items/3' },
{ n: 'deleteTemplateItem', r: () => adminApi.deleteTemplateItem(1, 3), e: 'DELETE /api/admin/packing-templates/1/items/3' },
])
})
it('FE-APISURF-013: pluginsApi maps every host-mediated plugin endpoint', async () => {
await assertCalls([
{ n: 'active', r: () => pluginsApi.active(), e: 'GET /api/plugins' },
{ n: 'placeDetails', r: () => pluginsApi.placeDetails(5), e: 'GET /api/place-details/5' },
{ n: 'tripWarnings', r: () => pluginsApi.tripWarnings(1), e: 'GET /api/trip-warnings/1' },
{ n: 'viewContributions', r: () => pluginsApi.viewContributions('places', 1), e: 'GET /api/view-contributions/places/1' },
{ n: 'mapMarkers', r: () => pluginsApi.mapMarkers(1), e: 'GET /api/map-markers/1' },
{ n: 'mapLayers', r: () => pluginsApi.mapLayers(1), e: 'GET /api/map-layers/1' },
{ n: 'pluginRoute', r: () => pluginsApi.pluginRoute('koffi', 'ev', { tripId: 1, waypoints: [{ lat: 1, lng: 2 }] }), e: 'POST /api/plugin-routes/koffi/ev' },
{ n: 'daySchedule', r: () => pluginsApi.daySchedule(1), e: 'GET /api/day-schedule/1' },
{ n: 'pdfSections', r: () => pluginsApi.pdfSections(1), e: 'GET /api/pdf-sections/1' },
{ n: 'atlasLayers', r: () => pluginsApi.atlasLayers(), e: 'GET /api/atlas-layers' },
{ n: 'journalEntryRows', r: () => pluginsApi.journalEntryRows(9), e: 'GET /api/journal-entry-rows/9' },
{ n: 'tripCardContributions', r: () => pluginsApi.tripCardContributions([1, 2]), e: 'GET /api/trip-card-contributions' },
{ n: 'myActivity', r: () => pluginsApi.myActivity(), e: 'GET /api/plugin-activity' },
{ n: 'userSettings', r: () => pluginsApi.userSettings('koffi'), e: 'GET /api/plugin-settings/koffi' },
{ n: 'runAction', r: () => pluginsApi.runAction('koffi', 'test connection'), e: 'POST /api/plugin-settings/koffi/actions/test%20connection' },
{ n: 'saveUserSettings', r: () => pluginsApi.saveUserSettings('koffi', { key: 'v' }), e: 'POST /api/plugin-settings/koffi' },
{ n: 'oauthStatus', r: () => pluginsApi.oauthStatus('koffi'), e: 'GET /api/plugin-oauth/koffi/status' },
{ n: 'oauthConnect', r: () => pluginsApi.oauthConnect('koffi'), e: 'POST /api/plugin-oauth/koffi/connect' },
{ n: 'oauthDisconnect', r: () => pluginsApi.oauthDisconnect('koffi'), e: 'POST /api/plugin-oauth/koffi/disconnect' },
])
})
it('FE-APISURF-014: airtrailApi maps the integration endpoints', async () => {
await assertCalls([
{ n: 'getSettings', r: () => airtrailApi.getSettings(), e: 'GET /api/integrations/airtrail/settings' },
{ n: 'saveSettings', r: () => airtrailApi.saveSettings({ url: 'https://at' }), e: 'PUT /api/integrations/airtrail/settings' },
{ n: 'status', r: () => airtrailApi.status(), e: 'GET /api/integrations/airtrail/status' },
{ n: 'test', r: () => airtrailApi.test({ url: 'https://at' }), e: 'POST /api/integrations/airtrail/test' },
{ n: 'sync', r: () => airtrailApi.sync(), e: 'POST /api/integrations/airtrail/sync' },
{ n: 'flights', r: () => airtrailApi.flights(), e: 'GET /api/integrations/airtrail/flights' },
{ n: 'import', r: () => airtrailApi.import(1, ['f1']), e: 'POST /api/trips/1/reservations/import/airtrail' },
])
})
it('FE-APISURF-015: journeyApi maps journal, entry and photo endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => journeyApi.list(), e: 'GET /api/journeys' },
{ n: 'create', r: () => journeyApi.create({ title: 'Asia' }), e: 'POST /api/journeys' },
{ n: 'get', r: () => journeyApi.get(2), e: 'GET /api/journeys/2' },
{ n: 'update', r: () => journeyApi.update(2, { title: 'Asia 24' }), e: 'PATCH /api/journeys/2' },
{ n: 'delete', r: () => journeyApi.delete(2), e: 'DELETE /api/journeys/2' },
{ n: 'suggestions', r: () => journeyApi.suggestions(), e: 'GET /api/journeys/suggestions' },
{ n: 'availableTrips', r: () => journeyApi.availableTrips(), e: 'GET /api/journeys/available-trips' },
{ n: 'addTrip', r: () => journeyApi.addTrip(2, 1), e: 'POST /api/journeys/2/trips' },
{ n: 'removeTrip', r: () => journeyApi.removeTrip(2, 1), e: 'DELETE /api/journeys/2/trips/1' },
{ n: 'listEntries', r: () => journeyApi.listEntries(2), e: 'GET /api/journeys/2/entries' },
{ n: 'createEntry', r: () => journeyApi.createEntry(2, { title: 'Day 1' }), e: 'POST /api/journeys/2/entries' },
{ n: 'updateEntry', r: () => journeyApi.updateEntry(9, { title: 'Day 2' }), e: 'PATCH /api/journeys/entries/9' },
{ n: 'deleteEntry', r: () => journeyApi.deleteEntry(9), e: 'DELETE /api/journeys/entries/9' },
{ n: 'reorderEntries', r: () => journeyApi.reorderEntries(2, [9, 8]), e: 'PUT /api/journeys/2/entries/reorder' },
{ n: 'addProviderPhotosToGallery', r: () => journeyApi.addProviderPhotosToGallery(2, 'immich', ['a1']), e: 'POST /api/journeys/2/gallery/provider-photos' },
{ n: 'addProviderPhoto', r: () => journeyApi.addProviderPhoto(9, 'immich', 'a1'), e: 'POST /api/journeys/entries/9/provider-photos' },
{ n: 'addProviderPhotos', r: () => journeyApi.addProviderPhotos(9, 'immich', ['a1']), e: 'POST /api/journeys/entries/9/provider-photos' },
{ n: 'linkPhoto', r: () => journeyApi.linkPhoto(9, 11), e: 'POST /api/journeys/entries/9/link-photo' },
{ n: 'unlinkPhoto', r: () => journeyApi.unlinkPhoto(9, 11), e: 'DELETE /api/journeys/entries/9/photos/11' },
{ n: 'deleteGalleryPhoto', r: () => journeyApi.deleteGalleryPhoto(2, 11), e: 'DELETE /api/journeys/2/gallery/11' },
{ n: 'updatePhoto', r: () => journeyApi.updatePhoto(11, { caption: 'x' }), e: 'PATCH /api/journeys/photos/11' },
{ n: 'deletePhoto', r: () => journeyApi.deletePhoto(11), e: 'DELETE /api/journeys/photos/11' },
{ n: 'addContributor', r: () => journeyApi.addContributor(2, 4, 'editor'), e: 'POST /api/journeys/2/contributors' },
{ n: 'updateContributor', r: () => journeyApi.updateContributor(2, 4, 'viewer'), e: 'PATCH /api/journeys/2/contributors/4' },
{ n: 'removeContributor', r: () => journeyApi.removeContributor(2, 4), e: 'DELETE /api/journeys/2/contributors/4' },
{ n: 'updatePreferences', r: () => journeyApi.updatePreferences(2, { hide_skeletons: true }), e: 'PATCH /api/journeys/2/preferences' },
{ n: 'getShareLink', r: () => journeyApi.getShareLink(2), e: 'GET /api/journeys/2/share-link' },
{ n: 'createShareLink', r: () => journeyApi.createShareLink(2, { share_map: true }), e: 'POST /api/journeys/2/share-link' },
{ n: 'deleteShareLink', r: () => journeyApi.deleteShareLink(2), e: 'DELETE /api/journeys/2/share-link' },
{ n: 'getPublicJourney', r: () => journeyApi.getPublicJourney('pub-tok'), e: 'GET /api/public/journey/pub-tok' },
])
})
it('FE-APISURF-053: memoriesApi maps the photo-provider endpoints', async () => {
await assertCalls([
{ n: 'status', r: () => memoriesApi.status('immich'), e: 'GET /api/integrations/memories/immich/status' },
{ n: 'search', r: () => memoriesApi.search('immich', { from: '2026-01-01', to: '2026-01-02', page: 1, size: 50 }), e: 'POST /api/integrations/memories/immich/search' },
{ n: 'albums', r: () => memoriesApi.albums('immich'), e: 'GET /api/integrations/memories/immich/albums' },
{ n: 'albumPhotos', r: () => memoriesApi.albumPhotos('immich', 'alb-1'), e: 'GET /api/integrations/memories/immich/albums/alb-1/photos' },
])
})
it('FE-APISURF-054: memoriesApi passes the album passphrase as a query parameter', async () => {
const rec = await traceOne(() => memoriesApi.albumPhotos('synologyphotos', 'alb-2', 'p/w?'))
expect(rec.url).toBe('/api/integrations/memories/synologyphotos/albums/alb-2/photos?passphrase=p%2Fw%3F')
})
it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => {
await assertCalls([
{ n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' },
{ n: 'maps.autocomplete', r: () => mapsApi.autocomplete('Rom'), e: 'POST /api/maps/autocomplete' },
{ n: 'maps.details', r: () => mapsApi.details('place/1'), e: 'GET /api/maps/details/place%2F1' },
{ n: 'maps.placePhoto', r: () => mapsApi.placePhoto('place/1'), e: 'GET /api/maps/place-photo/place%2F1' },
{ n: 'maps.reverse', r: () => mapsApi.reverse(41.9, 12.5), e: 'GET /api/maps/reverse' },
{ n: 'maps.resolveUrl', r: () => mapsApi.resolveUrl('https://maps.app.goo.gl/x'), e: 'POST /api/maps/resolve-url' },
{ n: 'maps.pois', r: () => mapsApi.pois('cafe', { south: 1, west: 2, north: 3, east: 4 }), e: 'GET /api/maps/pois' },
{ n: 'airports.search', r: () => airportsApi.search('BER'), e: 'GET /api/airports/search' },
{ n: 'airports.byIata', r: () => airportsApi.byIata('b/er'), e: 'GET /api/airports/b%2Fer' },
])
})
it('FE-APISURF-017: budgetApi maps item, member and settlement endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => budgetApi.list(1), e: 'GET /api/trips/1/budget' },
{ n: 'create', r: () => budgetApi.create(1, { name: 'Hotel' }), e: 'POST /api/trips/1/budget' },
{ n: 'update', r: () => budgetApi.update(1, 2, { name: 'Hostel' }), e: 'PUT /api/trips/1/budget/2' },
{ n: 'delete', r: () => budgetApi.delete(1, 2), e: 'DELETE /api/trips/1/budget/2' },
{ n: 'setMembers', r: () => budgetApi.setMembers(1, 2, [4, 5]), e: 'PUT /api/trips/1/budget/2/members' },
{ n: 'togglePaid', r: () => budgetApi.togglePaid(1, 2, 4, true), e: 'PUT /api/trips/1/budget/2/members/4/paid' },
{ n: 'setPayers', r: () => budgetApi.setPayers(1, 2, [{ user_id: 4, amount: 10 }]), e: 'PUT /api/trips/1/budget/2/payers' },
{ n: 'perPersonSummary', r: () => budgetApi.perPersonSummary(1), e: 'GET /api/trips/1/budget/summary/per-person' },
{ n: 'settlement', r: () => budgetApi.settlement(1), e: 'GET /api/trips/1/budget/settlement' },
{ n: 'createSettlement', r: () => budgetApi.createSettlement(1, { from_user_id: 4, to_user_id: 5, amount: 10 }), e: 'POST /api/trips/1/budget/settlements' },
{ n: 'updateSettlement', r: () => budgetApi.updateSettlement(1, 6, { from_user_id: 4, to_user_id: 5, amount: 12 }), e: 'PUT /api/trips/1/budget/settlements/6' },
{ n: 'deleteSettlement', r: () => budgetApi.deleteSettlement(1, 6), e: 'DELETE /api/trips/1/budget/settlements/6' },
{ n: 'reorderItems', r: () => budgetApi.reorderItems(1, [2, 3]), e: 'PUT /api/trips/1/budget/reorder/items' },
{ n: 'reorderCategories', r: () => budgetApi.reorderCategories(1, ['Food']), e: 'PUT /api/trips/1/budget/reorder/categories' },
])
})
it('FE-APISURF-018: filesApi maps file, trash and link endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => filesApi.list(1), e: 'GET /api/trips/1/files' },
{ n: 'update', r: () => filesApi.update(1, 3, { description: 'x' }), e: 'PUT /api/trips/1/files/3' },
{ n: 'delete', r: () => filesApi.delete(1, 3), e: 'DELETE /api/trips/1/files/3' },
{ n: 'toggleStar', r: () => filesApi.toggleStar(1, 3), e: 'PATCH /api/trips/1/files/3/star' },
{ n: 'restore', r: () => filesApi.restore(1, 3), e: 'POST /api/trips/1/files/3/restore' },
{ n: 'permanentDelete', r: () => filesApi.permanentDelete(1, 3), e: 'DELETE /api/trips/1/files/3/permanent' },
{ n: 'emptyTrash', r: () => filesApi.emptyTrash(1), e: 'DELETE /api/trips/1/files/trash/empty' },
{ n: 'addLink', r: () => filesApi.addLink(1, 3, { place_id: 5 }), e: 'POST /api/trips/1/files/3/link' },
{ n: 'removeLink', r: () => filesApi.removeLink(1, 3, 7), e: 'DELETE /api/trips/1/files/3/link/7' },
{ n: 'getLinks', r: () => filesApi.getLinks(1, 3), e: 'GET /api/trips/1/files/3/links' },
])
})
it('FE-APISURF-019: reservationsApi and accommodationsApi map booking endpoints', async () => {
await assertCalls([
{ n: 'reservations.list', r: () => reservationsApi.list(1), e: 'GET /api/trips/1/reservations' },
{ n: 'reservations.upcoming', r: () => reservationsApi.upcoming(), e: 'GET /api/reservations/upcoming' },
{ n: 'reservations.create', r: () => reservationsApi.create(1, { title: 'Hotel' }), e: 'POST /api/trips/1/reservations' },
{ n: 'reservations.update', r: () => reservationsApi.update(1, 2, { title: 'Hostel' }), e: 'PUT /api/trips/1/reservations/2' },
{ n: 'reservations.delete', r: () => reservationsApi.delete(1, 2), e: 'DELETE /api/trips/1/reservations/2' },
{ n: 'reservations.setTravelers', r: () => reservationsApi.setTravelers(1, 2, [4]), e: 'PUT /api/trips/1/reservations/2/travelers' },
{ n: 'reservations.updatePositions', r: () => reservationsApi.updatePositions(1, [{ id: 2, day_plan_position: 0 }], 3), e: 'PUT /api/trips/1/reservations/positions' },
{ n: 'reservations.importBookingConfirm', r: () => reservationsApi.importBookingConfirm(1, []), e: 'POST /api/trips/1/reservations/import/booking/confirm' },
{ n: 'reservations.importJobStatus', r: () => reservationsApi.importJobStatus(1, 'job-1'), e: 'GET /api/trips/1/reservations/import/jobs/job-1' },
{ n: 'accommodations.list', r: () => accommodationsApi.list(1), e: 'GET /api/trips/1/accommodations' },
{ n: 'accommodations.create', r: () => accommodationsApi.create(1, { place_id: 5, start_day_id: 1, end_day_id: 2 }), e: 'POST /api/trips/1/accommodations' },
{ n: 'accommodations.update', r: () => accommodationsApi.update(1, 4, { end_day_id: 3 }), e: 'PUT /api/trips/1/accommodations/4' },
{ n: 'accommodations.delete', r: () => accommodationsApi.delete(1, 4), e: 'DELETE /api/trips/1/accommodations/4' },
])
})
it('FE-APISURF-020: collabApi maps note, poll and message endpoints', async () => {
await assertCalls([
{ n: 'getNotes', r: () => collabApi.getNotes(1), e: 'GET /api/trips/1/collab/notes' },
{ n: 'createNote', r: () => collabApi.createNote(1, { title: 'Ideas' }), e: 'POST /api/trips/1/collab/notes' },
{ n: 'updateNote', r: () => collabApi.updateNote(1, 2, { title: 'More' }), e: 'PUT /api/trips/1/collab/notes/2' },
{ n: 'deleteNote', r: () => collabApi.deleteNote(1, 2), e: 'DELETE /api/trips/1/collab/notes/2' },
{ n: 'deleteNoteFile', r: () => collabApi.deleteNoteFile(1, 2, 3), e: 'DELETE /api/trips/1/collab/notes/2/files/3' },
{ n: 'getPolls', r: () => collabApi.getPolls(1), e: 'GET /api/trips/1/collab/polls' },
{ n: 'createPoll', r: () => collabApi.createPoll(1, { question: 'Where?', options: ['A', 'B'] }), e: 'POST /api/trips/1/collab/polls' },
{ n: 'votePoll', r: () => collabApi.votePoll(1, 2, 1), e: 'POST /api/trips/1/collab/polls/2/vote' },
{ n: 'closePoll', r: () => collabApi.closePoll(1, 2), e: 'PUT /api/trips/1/collab/polls/2/close' },
{ n: 'deletePoll', r: () => collabApi.deletePoll(1, 2), e: 'DELETE /api/trips/1/collab/polls/2' },
{ n: 'getMessages', r: () => collabApi.getMessages(1), e: 'GET /api/trips/1/collab/messages' },
{ n: 'sendMessage', r: () => collabApi.sendMessage(1, { text: 'hi' }), e: 'POST /api/trips/1/collab/messages' },
{ n: 'deleteMessage', r: () => collabApi.deleteMessage(1, 2), e: 'DELETE /api/trips/1/collab/messages/2' },
{ n: 'reactMessage', r: () => collabApi.reactMessage(1, 2, '👍'), e: 'POST /api/trips/1/collab/messages/2/react' },
{ n: 'linkPreview', r: () => collabApi.linkPreview(1, 'https://x.test/a?b=1'), e: 'GET /api/trips/1/collab/link-preview' },
])
})
it('FE-APISURF-021: the remaining namespaces map their endpoints', async () => {
await assertCalls([
{ n: 'addons.enabled', r: () => addonsApi.enabled(), e: 'GET /api/addons' },
{ n: 'health.features', r: () => healthApi.features(), e: 'GET /api/health/features' },
{ n: 'weather.get', r: () => weatherApi.get(41.9, 12.5, '2026-06-01'), e: 'GET /api/weather' },
{ n: 'weather.getCurrent', r: () => weatherApi.getCurrent(41.9, 12.5), e: 'GET /api/weather' },
{ n: 'weather.getDetailed', r: () => weatherApi.getDetailed(41.9, 12.5, '2026-06-01'), e: 'GET /api/weather/detailed' },
{ n: 'config.getPublicConfig', r: () => configApi.getPublicConfig(), e: 'GET /api/config' },
{ n: 'help.index', r: () => helpApi.index(), e: 'GET /api/help/index' },
{ n: 'help.page', r: () => helpApi.page('getting started'), e: 'GET /api/help/page/getting%20started' },
{ n: 'settings.get', r: () => settingsApi.get(), e: 'GET /api/settings' },
{ n: 'settings.set', r: () => settingsApi.set('theme', 'dark'), e: 'PUT /api/settings' },
{ n: 'settings.setBulk', r: () => settingsApi.setBulk({ theme: 'dark' }), e: 'POST /api/settings/bulk' },
{ n: 'backup.list', r: () => backupApi.list(), e: 'GET /api/backup/list' },
{ n: 'backup.create', r: () => backupApi.create(), e: 'POST /api/backup/create' },
{ n: 'backup.delete', r: () => backupApi.delete('b.zip'), e: 'DELETE /api/backup/b.zip' },
{ n: 'backup.restore', r: () => backupApi.restore('b.zip'), e: 'POST /api/backup/restore/b.zip' },
{ n: 'backup.getAutoSettings', r: () => backupApi.getAutoSettings(), e: 'GET /api/backup/auto-settings' },
{ n: 'backup.setAutoSettings', r: () => backupApi.setAutoSettings({ enabled: true }), e: 'PUT /api/backup/auto-settings' },
{ n: 'share.getLink', r: () => shareApi.getLink(1), e: 'GET /api/trips/1/share-link' },
{ n: 'share.createLink', r: () => shareApi.createLink(1, { edit: false }), e: 'POST /api/trips/1/share-link' },
{ n: 'share.deleteLink', r: () => shareApi.deleteLink(1), e: 'DELETE /api/trips/1/share-link' },
{ n: 'share.getSharedTrip', r: () => shareApi.getSharedTrip('tok'), e: 'GET /api/shared/tok' },
{ n: 'transit.geocode', r: () => transitApi.geocode('Roma Termini'), e: 'GET /api/transit/geocode' },
{ n: 'transit.plan', r: () => transitApi.plan({ from: 'a', to: 'b' }), e: 'GET /api/transit/plan' },
{ n: 'tripInvite.getLink', r: () => tripInviteApi.getLink(1), e: 'GET /api/trips/1/invite-link' },
{ n: 'tripInvite.createLink', r: () => tripInviteApi.createLink(1, 7), e: 'POST /api/trips/1/invite-link' },
{ n: 'tripInvite.deleteLink', r: () => tripInviteApi.deleteLink(1), e: 'DELETE /api/trips/1/invite-link' },
{ n: 'tripInvite.preview', r: () => tripInviteApi.preview('tok'), e: 'GET /api/trip-invites/tok' },
{ n: 'tripInvite.accept', r: () => tripInviteApi.accept('tok'), e: 'POST /api/trip-invites/tok/accept' },
{ n: 'notifications.getPreferences', r: () => notificationsApi.getPreferences(), e: 'GET /api/notifications/preferences' },
{ n: 'notifications.updatePreferences', r: () => notificationsApi.updatePreferences({ email: { trip_invite: true } }), e: 'PUT /api/notifications/preferences' },
{ n: 'notifications.testSmtp', r: () => notificationsApi.testSmtp('a@b.c'), e: 'POST /api/notifications/test-smtp' },
{ n: 'notifications.testWebhook', r: () => notificationsApi.testWebhook('https://hook'), e: 'POST /api/notifications/test-webhook' },
{ n: 'notifications.testNtfy', r: () => notificationsApi.testNtfy({ topic: 't' }), e: 'POST /api/notifications/test-ntfy' },
{ n: 'notifications.testChannel', r: () => notificationsApi.testChannel('plugin/ch'), e: 'POST /api/notifications/test/plugin%2Fch' },
{ n: 'inApp.list', r: () => inAppNotificationsApi.list(), e: 'GET /api/notifications/in-app' },
{ n: 'inApp.unreadCount', r: () => inAppNotificationsApi.unreadCount(), e: 'GET /api/notifications/in-app/unread-count' },
{ n: 'inApp.markRead', r: () => inAppNotificationsApi.markRead(3), e: 'PUT /api/notifications/in-app/3/read' },
{ n: 'inApp.markUnread', r: () => inAppNotificationsApi.markUnread(3), e: 'PUT /api/notifications/in-app/3/unread' },
{ n: 'inApp.markAllRead', r: () => inAppNotificationsApi.markAllRead(), e: 'PUT /api/notifications/in-app/read-all' },
{ n: 'inApp.delete', r: () => inAppNotificationsApi.delete(3), e: 'DELETE /api/notifications/in-app/3' },
{ n: 'inApp.deleteAll', r: () => inAppNotificationsApi.deleteAll(), e: 'DELETE /api/notifications/in-app/all' },
{ n: 'inApp.respond', r: () => inAppNotificationsApi.respond(3, 'positive'), e: 'POST /api/notifications/in-app/3/respond' },
])
})
})
describe('client > request payloads', () => {
it('FE-APISURF-022: reorder helpers wrap their ids in the contract field', async () => {
expect((await traceOne(() => daysApi.reorder(1, [3, 1, 2]))).body).toEqual({ orderedIds: [3, 1, 2] })
expect((await traceOne(() => packingApi.reorder(1, [2, 1]))).body).toEqual({ orderedIds: [2, 1] })
expect((await traceOne(() => todoApi.reorder(1, [9]))).body).toEqual({ orderedIds: [9] })
expect((await traceOne(() => budgetApi.reorderItems(1, [4, 5]))).body).toEqual({ orderedIds: [4, 5] })
expect((await traceOne(() => budgetApi.reorderCategories(1, ['Food', 'Fun']))).body)
.toEqual({ orderedCategories: ['Food', 'Fun'] })
expect((await traceOne(() => journeyApi.reorderEntries(2, [8, 7]))).body).toEqual({ orderedIds: [8, 7] })
})
it('FE-APISURF-023: user-id collections are sent as user_ids', async () => {
expect((await traceOne(() => assignmentsApi.setParticipants(1, 7, [4, 5]))).body).toEqual({ user_ids: [4, 5] })
expect((await traceOne(() => budgetApi.setMembers(1, 2, [4]))).body).toEqual({ user_ids: [4] })
expect((await traceOne(() => packingApi.setBagMembers(1, 2, [6]))).body).toEqual({ user_ids: [6] })
expect((await traceOne(() => reservationsApi.setTravelers(1, 2, [4, 6]))).body).toEqual({ user_ids: [4, 6] })
})
it('FE-APISURF-024: single-value helpers wrap their argument in the documented key', async () => {
expect((await traceOne(() => authApi.updateMapsKey(null))).body).toEqual({ maps_api_key: null })
expect((await traceOne(() => tripsApi.addMember(1, 'bob@x.test'))).body).toEqual({ identifier: 'bob@x.test' })
expect((await traceOne(() => tripsApi.transferOwnership(1, 9))).body).toEqual({ newOwnerId: 9 })
expect((await traceOne(() => tripsApi.createGuest(1, 'Anna'))).body).toEqual({ name: 'Anna' })
expect((await traceOne(() => daysApi.updateTransport(1, 2, 'walk'))).body).toEqual({ transport_mode: 'walk' })
expect((await traceOne(() => assignmentsApi.updateTransport(1, 7, null))).body).toEqual({ transport_mode: null })
expect((await traceOne(() => collabApi.votePoll(1, 2, 3))).body).toEqual({ option_index: 3 })
expect((await traceOne(() => collabApi.reactMessage(1, 2, '🎉'))).body).toEqual({ emoji: '🎉' })
expect((await traceOne(() => settingsApi.set('theme', 'dark'))).body).toEqual({ key: 'theme', value: 'dark' })
expect((await traceOne(() => settingsApi.setBulk({ a: 1 }))).body).toEqual({ settings: { a: 1 } })
expect((await traceOne(() => budgetApi.togglePaid(1, 2, 4, false))).body).toEqual({ paid: false })
expect((await traceOne(() => adminApi.updateBagTracking(true))).body).toEqual({ enabled: true })
expect((await traceOne(() => adminApi.updatePermissions({ edit: 'owner' }))).body)
.toEqual({ permissions: { edit: 'owner' } })
expect((await traceOne(() => pluginsApi.saveUserSettings('koffi', { k: 'v' }))).body)
.toEqual({ config: { k: 'v' } })
})
it('FE-APISURF-025: tripsApi.archive/unarchive send the is_archived flag', async () => {
expect((await traceOne(() => tripsApi.archive(3))).body).toEqual({ is_archived: true })
expect((await traceOne(() => tripsApi.unarchive(3))).body).toEqual({ is_archived: false })
})
it('FE-APISURF-026: placesApi bulk operations merge ids with the patch', async () => {
expect((await traceOne(() => placesApi.bulkDelete(1, [5, 6]))).body).toEqual({ ids: [5, 6] })
expect((await traceOne(() => placesApi.bulkUpdate(1, [5], { category_id: null }))).body)
.toEqual({ ids: [5], category_id: null })
})
it('FE-APISURF-027: placesApi.rate deletes on null and PUTs the value otherwise', async () => {
const cleared = await traceOne(() => placesApi.rate(1, 5, null))
expect(cleared.method).toBe('DELETE')
expect(cleared.url).toBe('/api/trips/1/places/5/rating')
const set = await traceOne(() => placesApi.rate(1, 5, 4))
expect(set.method).toBe('PUT')
expect(set.url).toBe('/api/trips/1/places/5/rating')
expect(set.body).toEqual({ rating: 4 })
})
it('FE-APISURF-028: airtrailApi.import only sends connections when there are any', async () => {
expect((await traceOne(() => airtrailApi.import(1, ['f1', 'f2']))).body).toEqual({ flightIds: ['f1', 'f2'] })
expect((await traceOne(() => airtrailApi.import(1, ['f1'], []))).body).toEqual({ flightIds: ['f1'] })
expect((await traceOne(() => airtrailApi.import(1, ['f1', 'f2'], [['f1', 'f2']]))).body)
.toEqual({ flightIds: ['f1', 'f2'], connections: [['f1', 'f2']] })
})
it('FE-APISURF-029: journeyApi provider-photo calls omit optional passphrase and media types', async () => {
expect((await traceOne(() => journeyApi.addProviderPhotosToGallery(2, 'immich', ['a1']))).body)
.toEqual({ provider: 'immich', asset_ids: ['a1'] })
expect((await traceOne(() => journeyApi.addProviderPhotosToGallery(2, 'immich', ['a1'], 'secret', ['video']))).body)
.toEqual({ provider: 'immich', asset_ids: ['a1'], passphrase: 'secret', media_types: ['video'] })
expect((await traceOne(() => journeyApi.addProviderPhoto(9, 'immich', 'a1', 'cap', 'secret'))).body)
.toEqual({ provider: 'immich', asset_id: 'a1', caption: 'cap', passphrase: 'secret' })
expect((await traceOne(() => journeyApi.addProviderPhotos(9, 'immich', ['a1'], 'cap'))).body)
.toEqual({ provider: 'immich', asset_ids: ['a1'], caption: 'cap' })
expect((await traceOne(() => journeyApi.addProviderPhotos(9, 'immich', ['a1'], 'cap', 'secret', ['image', 'video']))).body)
.toEqual({ provider: 'immich', asset_ids: ['a1'], caption: 'cap', passphrase: 'secret', media_types: ['image', 'video'] })
})
it('FE-APISURF-030: adminApi.pluginActivate only sends consent when granted', async () => {
expect((await traceOne(() => adminApi.pluginActivate('koffi'))).body).toEqual({})
expect((await traceOne(() => adminApi.pluginActivate('koffi', true))).body).toEqual({ consent: true })
})
it('FE-APISURF-031: adminApi.pluginInstall spreads its options next to the id', async () => {
expect((await traceOne(() => adminApi.pluginInstall('koffi'))).body).toEqual({ id: 'koffi' })
expect((await traceOne(() => adminApi.pluginInstall('koffi', { version: '2.0.0', withDependencies: true }))).body)
.toEqual({ id: 'koffi', version: '2.0.0', withDependencies: true })
})
it('FE-APISURF-032: tripInviteApi.createLink normalises a missing expiry to null', async () => {
expect((await traceOne(() => tripInviteApi.createLink(1))).body).toEqual({ expires_in_days: null })
expect((await traceOne(() => tripInviteApi.createLink(1, 14))).body).toEqual({ expires_in_days: 14 })
})
it('FE-APISURF-033: tripsApi.copy and shareApi.createLink default to an empty body', async () => {
expect((await traceOne(() => tripsApi.copy(3))).body).toEqual({})
expect((await traceOne(() => shareApi.createLink(1))).body).toEqual({})
})
it('FE-APISURF-034: authApi.passkey.delete sends the password in the DELETE body', async () => {
const rec = await traceOne(() => authApi.passkey.delete(3, 'hunter2'))
expect(rec.method).toBe('DELETE')
expect(rec.body).toEqual({ password: 'hunter2' })
})
})
describe('client > query parameters', () => {
it('FE-APISURF-035: tripsApi.list forwards arbitrary filters as query params', async () => {
const rec = await traceOne(() => tripsApi.list({ archived: true, q: 'rome' }))
const qs = new URLSearchParams(rec.url.split('?')[1])
expect(qs.get('archived')).toBe('true')
expect(qs.get('q')).toBe('rome')
})
it('FE-APISURF-036: filesApi.list only sets the trash flag when asked', async () => {
expect((await traceOne(() => filesApi.list(1))).url).toBe('/api/trips/1/files')
expect((await traceOne(() => filesApi.list(1, true))).url).toBe('/api/trips/1/files?trash=true')
})
it('FE-APISURF-037: budgetApi.settlement adds the base currency only when given', async () => {
expect((await traceOne(() => budgetApi.settlement(1))).url).toBe('/api/trips/1/budget/settlement')
expect((await traceOne(() => budgetApi.settlement(1, 'EUR'))).url).toBe('/api/trips/1/budget/settlement?base=EUR')
})
it('FE-APISURF-038: collabApi.getMessages appends the before cursor', async () => {
expect((await traceOne(() => collabApi.getMessages(1))).url).toBe('/api/trips/1/collab/messages')
expect((await traceOne(() => collabApi.getMessages(1, '2026-01-01'))).url)
.toBe('/api/trips/1/collab/messages?before=2026-01-01')
})
it('FE-APISURF-039: adminApi.pluginBrowse only sets refresh when forced', async () => {
expect((await traceOne(() => adminApi.pluginBrowse())).url).toBe('/api/admin/plugins/registry')
expect((await traceOne(() => adminApi.pluginBrowse(true))).url).toBe('/api/admin/plugins/registry?refresh=1')
})
it('FE-APISURF-040: adminApi.auditLog and llmLocalModels pass their params through', async () => {
const audit = await traceOne(() => adminApi.auditLog({ limit: 50, offset: 100 }))
expect(new URLSearchParams(audit.url.split('?')[1]).get('limit')).toBe('50')
expect(new URLSearchParams(audit.url.split('?')[1]).get('offset')).toBe('100')
const models = await traceOne(() => adminApi.llmLocalModels('http://ollama:11434'))
expect(new URLSearchParams(models.url.split('?')[1]).get('baseUrl')).toBe('http://ollama:11434')
})
it('FE-APISURF-041: mapsApi flattens the POI bbox into the query string', async () => {
const rec = await traceOne(() => mapsApi.pois('cafe', { south: 41.8, west: 12.4, north: 42.0, east: 12.6 }, 'de'))
const qs = new URLSearchParams(rec.url.split('?')[1])
expect(qs.get('category')).toBe('cafe')
expect(qs.get('south')).toBe('41.8')
expect(qs.get('west')).toBe('12.4')
expect(qs.get('north')).toBe('42')
expect(qs.get('east')).toBe('12.6')
expect(qs.get('lang')).toBe('de')
})
it('FE-APISURF-042: weatherApi sends lat/lng plus the date or language', async () => {
const forecast = await traceOne(() => weatherApi.get(41.9, 12.5, '2026-06-01'))
const fq = new URLSearchParams(forecast.url.split('?')[1])
expect([fq.get('lat'), fq.get('lng'), fq.get('date')]).toEqual(['41.9', '12.5', '2026-06-01'])
const current = await traceOne(() => weatherApi.getCurrent(41.9, 12.5, 'de'))
expect(new URLSearchParams(current.url.split('?')[1]).get('lang')).toBe('de')
})
it('FE-APISURF-043: pluginsApi joins trip ids and defaults the activity limit', async () => {
expect((await traceOne(() => pluginsApi.tripCardContributions([1, 2, 3]))).url)
.toBe('/api/trip-card-contributions?tripIds=1,2,3')
expect((await traceOne(() => pluginsApi.myActivity())).url).toBe('/api/plugin-activity?limit=200')
expect((await traceOne(() => pluginsApi.myActivity(5))).url).toBe('/api/plugin-activity?limit=5')
})
it('FE-APISURF-044: packing/todo category assignees encode the category name', async () => {
const packing = await traceOne(() => packingApi.setCategoryAssignees(1, 'Rain gear/Wet', [4]))
expect(packing.url).toBe('/api/trips/1/packing/category-assignees/Rain%20gear%2FWet')
expect(packing.body).toEqual({ user_ids: [4] })
const todo = await traceOne(() => todoApi.setCategoryAssignees(1, 'Before & after', [5]))
expect(todo.url).toBe('/api/trips/1/todo/category-assignees/Before%20%26%20after')
expect(todo.body).toEqual({ user_ids: [5] })
})
it('FE-APISURF-045: collabApi.linkPreview URL-encodes the previewed link', async () => {
const rec = await traceOne(() => collabApi.linkPreview(1, 'https://x.test/a?b=1&c=2'))
expect(rec.url).toBe('/api/trips/1/collab/link-preview?url=https%3A%2F%2Fx.test%2Fa%3Fb%3D1%26c%3D2')
})
})
describe('client > multipart uploads', () => {
// jsdom FormData bodies deadlock inside MSW, so uploads are asserted at the
// axios boundary instead (same approach as tests/integration/api/client.test.ts).
function spyPost() {
return vi.spyOn(apiClient, 'post')
.mockResolvedValue({ data: { ok: true } } as unknown as AxiosResponse)
}
it('FE-APISURF-046: every upload opts out of the 8s global timeout', async () => {
const post = spyPost()
const fd = new FormData()
await authApi.uploadAvatar(fd)
await tripsApi.uploadCover(3, fd)
await filesApi.upload(1, fd)
await journeyApi.uploadPhotos(9, fd)
await journeyApi.uploadGalleryPhotos(2, fd)
await journeyApi.uploadGalleryVideo(2, fd)
await journeyApi.uploadCover(2, fd)
await collabApi.uploadNoteFile(1, 2, fd)
expect(post.mock.calls.map(c => c[0])).toEqual([
'/auth/avatar',
'/trips/3/cover',
'/trips/1/files',
'/journeys/entries/9/photos',
'/journeys/2/gallery/photos',
'/journeys/2/gallery/video',
'/journeys/2/cover',
'/trips/1/collab/notes/2/files',
])
for (const call of post.mock.calls) {
expect(call[1]).toBeInstanceOf(FormData)
expect(call[2]).toMatchObject({ timeout: 0 })
expect((call[2] as { headers: Record<string, string> }).headers['Content-Type']).toBe('multipart/form-data')
}
})
it('FE-APISURF-047: postMultipart forwards progress, abort signal and idempotency key', async () => {
const post = spyPost()
const onUploadProgress = vi.fn((_e: unknown) => {})
const controller = new AbortController()
await filesApi.upload(1, new FormData(), {
onUploadProgress,
signal: controller.signal,
idempotencyKey: 'fixed-key',
})
const config = post.mock.calls[0][2] as {
headers: Record<string, string>
onUploadProgress?: unknown
signal?: AbortSignal
timeout: number
}
expect(config.headers['X-Idempotency-Key']).toBe('fixed-key')
expect(config.onUploadProgress).toBe(onUploadProgress)
expect(config.signal).toBe(controller.signal)
expect(config.timeout).toBe(0)
})
it('FE-APISURF-048: placesApi.uploadImage posts the file under the image field', async () => {
const post = spyPost()
const file = new File(['bytes'], 'shot.jpg', { type: 'image/jpeg' })
await placesApi.uploadImage(1, 5, file)
expect(post.mock.calls[0][0]).toBe('/trips/1/places/5/image')
const fd = post.mock.calls[0][1] as FormData
expect((fd.get('image') as File).name).toBe('shot.jpg')
})
it('FE-APISURF-049: placesApi.importGpx only appends the flags it was given', async () => {
const post = spyPost()
const file = new File(['<gpx/>'], 'track.gpx')
await placesApi.importGpx(1, file)
expect(post.mock.calls[0][0]).toBe('/trips/1/places/import/gpx')
const bare = post.mock.calls[0][1] as FormData
expect(bare.get('importWaypoints')).toBeNull()
expect(bare.get('importRoutes')).toBeNull()
expect(bare.get('importTracks')).toBeNull()
await placesApi.importGpx(1, file, { waypoints: true, routes: false, tracks: true })
const flagged = post.mock.calls[1][1] as FormData
expect(flagged.get('importWaypoints')).toBe('true')
expect(flagged.get('importRoutes')).toBe('false')
expect(flagged.get('importTracks')).toBe('true')
})
it('FE-APISURF-050: placesApi.importMapFile appends the point/path flags', async () => {
const post = spyPost()
const file = new File(['{}'], 'map.kml')
await placesApi.importMapFile(1, file)
expect(post.mock.calls[0][0]).toBe('/trips/1/places/import/map')
expect((post.mock.calls[0][1] as FormData).get('importPoints')).toBeNull()
await placesApi.importMapFile(1, file, { points: true, paths: false })
const flagged = post.mock.calls[1][1] as FormData
expect(flagged.get('importPoints')).toBe('true')
expect(flagged.get('importPaths')).toBe('false')
})
it('FE-APISURF-051: booking import posts every file plus the extraction mode', async () => {
const post = spyPost()
const files = [new File(['a'], 'a.pdf'), new File(['b'], 'b.pdf')]
await reservationsApi.importBookingPreview(1, files, 'force-ai')
expect(post.mock.calls[0][0]).toBe('/trips/1/reservations/import/booking')
const preview = post.mock.calls[0][1] as FormData
expect(preview.getAll('files')).toHaveLength(2)
expect(preview.get('mode')).toBe('force-ai')
await reservationsApi.importBookingAsync(1, files)
expect(post.mock.calls[1][0]).toBe('/trips/1/reservations/import/booking/async')
expect((post.mock.calls[1][1] as FormData).get('mode')).toBe('no-ai')
})
it('FE-APISURF-052: adminApi.pluginUpload and backupApi.uploadRestore name their form fields', async () => {
const post = spyPost()
await adminApi.pluginUpload(new File(['zip'], 'plugin.zip'))
expect(post.mock.calls[0][0]).toBe('/admin/plugins/upload')
expect(((post.mock.calls[0][1] as FormData).get('file') as File).name).toBe('plugin.zip')
await backupApi.uploadRestore(new File(['zip'], 'backup.zip'))
expect(post.mock.calls[1][0]).toBe('/backup/upload-restore')
expect(((post.mock.calls[1][1] as FormData).get('backup') as File).name).toBe('backup.zip')
})
})
+567 -70
View File
@@ -1,5 +1,7 @@
import axios, { AxiosInstance } from 'axios'
import type { z } from 'zod'
import type { Place } from '../types'
import { randomId } from '../utils/randomId'
import {
weatherResultSchema, type WeatherResult,
inAppListResultSchema, type InAppListResult,
@@ -7,29 +9,33 @@ import {
channelTestResultSchema,
mapsSearchResultSchema, mapsAutocompleteResultSchema, mapsPlaceDetailsResultSchema,
mapsPlacePhotoResultSchema, mapsReverseResultSchema, mapsResolveUrlResultSchema,
mapsPlaceEnrichmentResultSchema,
type NotificationRespondRequest,
type SettingUpsertRequest, type SettingsBulkRequest,
type JourneyCreateRequest, type JourneyAddTripRequest,
type JourneyCreateRequest, type JourneyAddTripRequest, type JourneyTracksResponse,
type JourneyStats, type BookRecord, type BookSaveRequest,
type JourneyReorderEntriesRequest, type JourneyProviderPhotosRequest,
type JourneyShareLinkRequest,
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
type ResetPasswordRequest, type ChangePasswordRequest,
type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest,
type TripAddMemberRequest, type AssignmentReorderRequest,
type TripAddMemberRequest, type TripTransferOwnershipRequest,
type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest,
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, type ActiveTripResponse,
type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest,
type PlaceCreateRequest, type PlaceUpdateRequest,
type ReservationCreateRequest, type ReservationUpdateRequest,
type AccommodationCreateRequest, type AccommodationUpdateRequest,
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
type PackingCreateItemRequest, type PackingUpdateItemRequest,
type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest,
type TodoCreateItemRequest, type TodoUpdateItemRequest,
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest,
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest,
type PlaceBulkDeleteRequest,
type PlaceBulkUpdateRequest,
type DayNoteCreateRequest, type DayNoteUpdateRequest,
type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest,
type PackingCategoryAssigneesRequest,
type PackingCategoryAssigneesRequest, type PackingApplyTemplateRequest,
type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest,
type TodoCategoryAssigneesRequest,
type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest,
@@ -41,9 +47,16 @@ import {
type BookingImportPreviewItem,
type BookingImportPreviewResponse,
type BookingImportConfirmResponse,
type BookingImportMode,
type StorageAdminState,
type StorageBackend,
type StorageConfigPut,
type StorageTestResponse,
type StorageUsage,
} from '@trek/shared'
import { getSocketId } from './websocket'
import { isReachable, probeNow } from '../sync/connectivity'
import { probeNow } from '../sync/connectivity'
import { downloadBlob } from '../utils/fileDownload'
/**
* Validate a response payload against its @trek/shared Zod schema — but only in
@@ -101,6 +114,9 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
uk: 'Занадто багато спроб. Спробуйте пізніше.',
sv: 'För många försök. Prova igen senare.',
ca: 'Massa intents. Torneu-ho a provar més tard.',
gr: 'Πάρα πολλές προσπάθειες. Δοκιμάστε ξανά αργότερα.',
vi: 'Quá nhiều lần thử. Vui lòng thử lại sau.',
}
function translateRateLimit(): string {
@@ -136,10 +152,7 @@ apiClient.interceptors.request.use(
// The mutation queue sets its own pre-generated key; skip if already set.
const method = (config.method ?? '').toLowerCase()
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
const key = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2)
config.headers['X-Idempotency-Key'] = key
config.headers['X-Idempotency-Key'] = randomId()
}
return config
},
@@ -175,13 +188,17 @@ apiClient.interceptors.response.use(
// distinguish a proxy auth challenge from a genuine outage. If the server
// is reachable, a top-level reload lets the edge proxy run its auth flow.
if (!error.response && navigator.onLine) {
await probeNow()
// Both the original request and the health probe failed while the device
// has a network interface. This matches the proxy-auth-challenge pattern
// (CF Access / Pangolin intercept all requests and CORS-block XHR).
// Guard with sessionStorage to prevent reload loops (server genuinely
// down would also land here, but only reloads once).
if (!isReachable()) {
// Only an actual edge-proxy auth wall warrants tearing down the SW to
// reauth: a reachable proxy (CF Access / Pangolin) that intercepts /api
// with a cross-origin redirect or an HTML login page. A genuine offline
// boot ALSO lands here — navigator.onLine reflects a network interface,
// not reachability, and is routinely true on mobile while offline. So
// gate strictly on a positive proxy signal; on plain offline do nothing
// and let the request reject so the cached shell + IndexedDB serve the
// app. Unregistering the SW here reloaded into a dead network and broke
// PWA offline mode (#1346).
const state = await probeNow()
if (state === 'proxy-wall') {
const { pathname } = window.location
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
sessionStorage.setItem('proxy_reauth_attempted', '1')
@@ -220,9 +237,11 @@ apiClient.interceptors.response.use(
}
if (error.response?.status === 429) {
const translated = translateRateLimit()
const data = error.response.data as { error?: string } | undefined
if (data && typeof data === 'object') {
data.error = translated
const data = error.response.data
// Only a plain object body carries an `error` field worth overwriting;
// an array (a validation-error list) or a string is replaced outright.
if (data && typeof data === 'object' && !Array.isArray(data)) {
(data as { error?: string }).error = translated
} else {
error.response.data = { error: translated }
}
@@ -232,6 +251,40 @@ apiClient.interceptors.response.use(
}
)
/**
* POST a FormData body — the ONLY way this client should upload a file.
*
* The shared axios instance carries `timeout: 8000`, and axios' timeout is a whole-
* request deadline rather than an idle one. A file upload that takes longer than 8s to
* push its body — a phone photo on a slow uplink, a 500 MB document — is aborted
* mid-stream, which the server reports as a multer "Request aborted" (#1495).
*
* Every upload therefore has to opt out with `timeout: 0`. That opt-out used to be
* hand-written per call site, so it was forgotten on 7 of 15 — including the two 500 MB
* endpoints (documents, backup restore). Centralizing makes the correct behavior the
* default instead of something you have to remember.
*
* The Content-Type is set for clarity only: axios unsets it for FormData in the browser
* so the platform can generate the multipart boundary.
*/
export interface UploadOptions {
onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void
idempotencyKey?: string
signal?: AbortSignal
}
export function postMultipart<T = any>(url: string, formData: FormData, opts?: UploadOptions): Promise<T> {
return apiClient.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data',
...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}),
},
timeout: 0,
onUploadProgress: opts?.onUploadProgress,
signal: opts?.signal,
}).then(r => r.data as T)
}
export const authApi = {
register: (data: RegisterRequest) => apiClient.post('/auth/register', data).then(r => r.data),
validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data),
@@ -246,7 +299,7 @@ export const authApi = {
updateSettings: (data: Record<string, unknown>) => apiClient.put('/auth/me/settings', data).then(r => r.data),
getSettings: () => apiClient.get('/auth/me/settings').then(r => r.data),
listUsers: () => apiClient.get('/auth/users').then(r => r.data),
uploadAvatar: (formData: FormData) => apiClient.post('/auth/avatar', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
uploadAvatar: (formData: FormData) => postMultipart('/auth/avatar', formData),
deleteAvatar: () => apiClient.delete('/auth/avatar').then(r => r.data),
getAppConfig: () => apiClient.get('/auth/app-config').then(r => r.data),
updateAppSettings: (data: Record<string, unknown>) => apiClient.put('/auth/app-settings', data).then(r => r.data),
@@ -262,6 +315,14 @@ export const authApi = {
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
},
// Keys for the public API. Same shape as the MCP tokens above and a separate
// credential: one drives the assistant tools, the other reads trips over HTTP,
// and a key of the wrong kind is refused like one that does not exist.
apiKeys: {
list: () => apiClient.get('/auth/api-tokens').then(r => r.data),
create: (name: string) => apiClient.post('/auth/api-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
delete: (id: number) => apiClient.delete(`/auth/api-tokens/${id}`).then(r => r.data),
},
passkey: {
registerOptions: (password: string) => apiClient.post('/auth/passkey/register/options', { password }).then(r => r.data),
registerVerify: (attestationResponse: unknown, name?: string) => apiClient.post('/auth/passkey/register/verify', { attestationResponse, name }).then(r => r.data),
@@ -325,14 +386,22 @@ export const tripsApi = {
list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data),
create: (data: TripCreateRequest) => apiClient.post('/trips', data).then(r => r.data),
get: (id: number | string) => apiClient.get(`/trips/${id}`).then(r => r.data),
// The startup redirect's one lookup — deliberately not list(), which would pull
// every trip with its counts just to read one id.
active: (): Promise<ActiveTripResponse> => apiClient.get('/trips/active').then(r => r.data),
update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data),
uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
uploadCover: (id: number | string, formData: FormData) => postMultipart(`/trips/${id}/cover`, formData),
searchCoverImages: (query: string) => apiClient.get('/trips/cover-images/search', { params: { query } }).then(r => r.data),
archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier } satisfies TripAddMemberRequest).then(r => r.data),
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
transferOwnership: (id: number | string, newOwnerId: number) => apiClient.post(`/trips/${id}/transfer`, { newOwnerId } satisfies TripTransferOwnershipRequest).then(r => r.data),
createGuest: (id: number | string, name: string) => apiClient.post(`/trips/${id}/guests`, { name } satisfies TripCreateGuestRequest).then(r => r.data),
renameGuest: (id: number | string, userId: number, name: string) => apiClient.put(`/trips/${id}/guests/${userId}`, { name } satisfies TripRenameGuestRequest).then(r => r.data),
deleteGuest: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/guests/${userId}`).then(r => r.data),
copy: (id: number | string, data?: TripCopyRequest) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data),
}
@@ -341,6 +410,8 @@ export const daysApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
// Whole-day default route mode (#1281); per-segment leg modes override it.
updateTransport: (tripId: number | string, dayId: number | string, mode: string | null) => apiClient.put(`/trips/${tripId}/days/${dayId}/transport`, { transport_mode: mode }).then(r => r.data),
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data),
}
@@ -352,20 +423,29 @@ export const placesApi = {
update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
uploadImage: (tripId: number | string, id: number | string, file: File) => {
const fd = new FormData()
fd.append('image', file)
return postMultipart<{ place: Place }>(`/trips/${tripId}/places/${id}/image`, fd)
},
rate: (tripId: number | string, id: number | string, rating: number | null): Promise<{ place: Place }> =>
rating === null
? apiClient.delete(`/trips/${tripId}/places/${id}/rating`).then(r => r.data)
: apiClient.put(`/trips/${tripId}/places/${id}/rating`, { rating }).then(r => r.data),
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
const fd = new FormData()
fd.append('file', file)
if (opts?.waypoints !== undefined) fd.append('importWaypoints', String(opts.waypoints))
if (opts?.routes !== undefined) fd.append('importRoutes', String(opts.routes))
if (opts?.tracks !== undefined) fd.append('importTracks', String(opts.tracks))
return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
return postMultipart(`/trips/${tripId}/places/import/gpx`, fd)
},
importMapFile: (tripId: number | string, file: File, opts?: { points?: boolean; paths?: boolean }) => {
const fd = new FormData()
fd.append('file', file)
if (opts?.points !== undefined) fd.append('importPoints', String(opts.points))
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
return postMultipart(`/trips/${tripId}/places/import/map`, fd)
},
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
@@ -373,6 +453,8 @@ export const placesApi = {
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
bulkDelete: (tripId: number | string, ids: number[]) =>
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
bulkUpdate: (tripId: number | string, ids: number[], data: Omit<PlaceBulkUpdateRequest, 'ids'>) =>
apiClient.post(`/trips/${tripId}/places/bulk-update`, { ids, ...data } satisfies PlaceBulkUpdateRequest).then(r => r.data),
}
export const assignmentsApi = {
@@ -385,6 +467,10 @@ export const assignmentsApi = {
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data),
updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
// Per-segment travel mode (#1281): mode of the leg leaving this stop (null = inherit day default).
// direction defaults to 'outgoing' server-side, so only send it for the incoming (boundary-leg) case
// and keep the outgoing payload byte-for-byte identical to the pre-#1281 shape.
updateTransport: (tripId: number | string, id: number, mode: string | null, direction: 'outgoing' | 'incoming' = 'outgoing') => apiClient.put(`/trips/${tripId}/assignments/${id}/transport`, (direction === 'incoming' ? { transport_mode: mode, direction } : { transport_mode: mode }) satisfies Partial<AssignmentTransportRequest>).then(r => r.data),
}
export const packingApi = {
@@ -394,10 +480,14 @@ export const packingApi = {
update: (tripId: number | string, id: number, data: PackingUpdateItemRequest) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data),
setSharing: (tripId: number | string, id: number, data: PackingSetSharingRequest) => apiClient.put(`/trips/${tripId}/packing/${id}/sharing`, data).then(r => r.data),
clone: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/packing/${id}/clone`).then(r => r.data),
addContributor: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/packing/${id}/contributors`).then(r => r.data),
removeContributor: (tripId: number | string, id: number, userId: number) => apiClient.delete(`/trips/${tripId}/packing/${id}/contributors/${userId}`).then(r => r.data),
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data),
listTemplates: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/templates`).then(r => r.data),
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
applyTemplate: (tripId: number | string, templateId: number, visibility: 'common' | 'personal' = 'common') => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`, { visibility } satisfies PackingApplyTemplateRequest).then(r => r.data),
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds } satisfies PackingBagMembersRequest).then(r => r.data),
listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data),
@@ -442,6 +532,85 @@ export const adminApi = {
updateOidc: (data: Record<string, unknown>) => apiClient.put('/admin/oidc', data).then(r => r.data),
addons: () => apiClient.get('/admin/addons').then(r => r.data),
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
plugins: () => apiClient.get('/admin/plugins').then(r => r.data),
pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data),
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
pluginInstall: (id: string, opts?: { version?: string; constraint?: string; withDependencies?: boolean }) =>
apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data),
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
// `version` pins the exact version to install (the rollback path); omitted, the server
// resolves the newest TREK-compatible version itself.
pluginUpdate: (id: string, version?: string) =>
apiClient.post(`/admin/plugins/${id}/update`, version ? { version } : {}).then(r => r.data),
// Release a per-plugin update hold (set by a deliberate non-latest install).
pluginResumeUpdates: (id: string) => apiClient.post(`/admin/plugins/${id}/resume-updates`).then(r => r.data),
// Re-trust a ROTATED author signing key and update, in ONE call. `publicKey` is the
// full key the admin was shown (not a fingerprint): the server compares it exactly, so
// it can refuse if the registry entry was re-keyed again since the dialog rendered.
pluginRetrust: (id: string, version: string, publicKey: string) =>
apiClient.post(`/admin/plugins/${id}/retrust`, { version, publicKey }).then(r => r.data),
pluginUninstall: (id: string, deleteData: boolean) => apiClient.post(`/admin/plugins/${id}/uninstall`, { deleteData }).then(r => r.data),
pluginRescan: () => apiClient.post('/admin/plugins/rescan').then(r => r.data),
pluginUpload: (file: File) => { const fd = new FormData(); fd.append('file', file); return postMultipart('/admin/plugins/upload', fd) },
// Dev-link (dev-only): register a plugin from a local built dir + hot-reload it.
pluginLink: (path: string) => apiClient.post('/admin/plugins/link', { path }).then(r => r.data),
pluginReload: (id: string) => apiClient.post(`/admin/plugins/${id}/reload`).then(r => r.data),
// Operator-supplied egress hosts: a plugin talking to a SELF-HOSTED service can't name
// the operator's hostname in its manifest, so the admin adds it here. Saving re-spawns
// the plugin with the widened allow-list.
pluginEgressHosts: (id: string): Promise<{ supported: boolean; hosts: string[] }> =>
apiClient.get(`/admin/plugins/${id}/egress-hosts`).then(r => r.data),
pluginSetEgressHosts: (id: string, hosts: string[]): Promise<{ hosts: string[] }> =>
apiClient.put(`/admin/plugins/${id}/egress-hosts`, { hosts }).then(r => r.data),
pluginErrors: (id: string) => apiClient.get(`/admin/plugins/${id}/errors`).then(r => r.data),
pluginAudit: (id: string) => apiClient.get(`/admin/plugins/${id}/audit`).then(r => r.data),
// Local LLM (Ollama) management for the AI-parsing addon.
llmLocalModels: (baseUrl: string): Promise<{ models: { name: string; size: number }[] }> =>
apiClient.get('/admin/llm/local/models', { params: { baseUrl } }).then(r => r.data),
/** Pull a model, streaming Ollama's NDJSON progress to `onProgress`. */
llmLocalPull: async (
baseUrl: string,
model: string,
onProgress: (p: { status?: string; total?: number; completed?: number; error?: string }) => void,
): Promise<void> => {
const res = await fetch('/api/admin/llm/local/pull', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ baseUrl, model }),
})
if (!res.ok) {
let msg = `Pull failed (${res.status})`
try { msg = (await res.json())?.error ?? msg } catch { /* non-json */ }
throw new Error(msg)
}
// An accepted request without a stream can't be followed to completion.
if (!res.body) throw new Error('Pull returned no progress stream')
const reader = res.body.getReader()
const dec = new TextDecoder()
let buf = ''
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += dec.decode(value, { stream: true })
// split() always yields at least one element; the last one is the
// trailing (possibly partial) line carried into the next chunk.
const lines = buf.split('\n')
buf = lines.pop()!
for (const line of lines) {
if (!line.trim()) continue
// Only the parse is swallowed — a throw from onProgress aborts the pull.
let frame: { status?: string; total?: number; completed?: number; error?: string }
try { frame = JSON.parse(line) } catch { continue }
onProgress(frame)
}
}
} finally {
reader.cancel().catch(() => {})
}
},
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
@@ -451,6 +620,8 @@ export const adminApi = {
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
getPlacesEnrich: () => apiClient.get('/admin/places-enrich').then(r => r.data),
updatePlacesEnrich: (enabled: boolean) => apiClient.put('/admin/places-enrich', { enabled }).then(r => r.data),
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
@@ -465,7 +636,8 @@ export const adminApi = {
updateTemplateItem: (templateId: number, itemId: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${templateId}/items/${itemId}`, data).then(r => r.data),
deleteTemplateItem: (templateId: number, itemId: number) => apiClient.delete(`/admin/packing-templates/${templateId}/items/${itemId}`).then(r => r.data),
listInvites: () => apiClient.get('/admin/invites').then(r => r.data),
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
listInviteTrips: () => apiClient.get('/admin/invites/trips').then(r => r.data),
createInvite: (data: { max_uses: number; expires_in_days?: number; trip_id?: number | null }) => apiClient.post('/admin/invites', data).then(r => r.data),
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
auditLog: (params?: { limit?: number; offset?: number }) =>
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
@@ -482,12 +654,247 @@ export const adminApi = {
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
updateDefaultUserSettings: (settings: Record<string, unknown>) => apiClient.put('/admin/default-user-settings', settings).then(r => r.data),
getStorage: (): Promise<StorageAdminState> => apiClient.get('/admin/storage').then(r => r.data),
updateStorage: (config: StorageConfigPut): Promise<StorageAdminState> =>
apiClient.put('/admin/storage', config).then(r => r.data),
// A probe is bounded by the target driver's own timeout (default 30s, ×2 with
// one retry) and a mirror probes its targets sequentially — the instance-wide
// 8s axios timeout would abort a legitimate slow probe, so this call carries
// its own generous ceiling.
testStorageBackend: (backend: StorageBackend): Promise<StorageTestResponse> =>
apiClient.post('/admin/storage/test', { backend }, { timeout: 120_000 }).then(r => r.data),
startStorageBackfill: (backend: string): Promise<{ started: true }> =>
apiClient.post(`/admin/storage/backends/${encodeURIComponent(backend)}/backfill`).then(r => r.data),
cancelStorageBackfill: (backend: string): Promise<{ cancelled: true }> =>
apiClient.delete(`/admin/storage/backends/${encodeURIComponent(backend)}/backfill`).then(r => r.data),
startStorageMigration: (category: string, to: string): Promise<{ started: true }> =>
apiClient.post('/admin/storage/migrations', { category, to }).then(r => r.data),
cancelStorageMigration: (category: string): Promise<{ cancelled: true }> =>
apiClient.delete(`/admin/storage/migrations/${encodeURIComponent(category)}`).then(r => r.data),
// A full scan of a large install can exceed the 8s instance timeout.
refreshStorageStats: (): Promise<StorageUsage> =>
apiClient.post('/admin/storage/stats/refresh', undefined, { timeout: 120_000 }).then(r => r.data),
}
export const addonsApi = {
enabled: () => apiClient.get('/addons').then(r => r.data),
}
/** A host-rendered column/action a plugin contributes into a native planner view
* (reservations/places/day) via the tableContributor hook. Every field is bounded +
* normalized server-side; a column url is guaranteed http/https/mailto. */
export type ViewContribution =
| { kind: 'column'; pluginId: string; entityId: number; id: string; label: string; value?: string; url?: string; icon?: string; tone: 'default' | 'success' | 'warn' | 'danger' }
| { kind: 'action'; pluginId: string; entityId: number; id: string; label: string; icon?: string; target: { kind: 'frame'; sub: string } | { kind: 'route'; method: 'GET' | 'POST'; sub: string } }
/** A badge a plugin adds to a dashboard trip card via the tripCardProvider hook.
* Bounded + normalized server-side; the url is guaranteed http/https/mailto. */
export interface TripCardBadge {
pluginId: string; tripId: number; id: string; label: string;
value?: string; icon?: string; tone: 'default' | 'success' | 'warn' | 'danger'; url?: string;
}
export interface PluginMapMarker {
pluginId: string; id: string; lat: number; lng: number;
label?: string; popupText?: string; url?: string; icon?: string;
tone: 'default' | 'success' | 'warn' | 'danger'
}
/** One shape of a plugin map layer (mapLayerProvider hook). Server-normalized:
* coordinates range-checked, vertex budget capped, styling clamped to the tone
* palette + bounded numerics — never free-form CSS or markup. */
export interface PluginMapLayerFeature {
type: 'polyline' | 'polygon' | 'circle';
points?: Array<[number, number]>;
center?: [number, number];
radiusM?: number;
tone: 'default' | 'success' | 'warn' | 'danger';
width: number;
dash: 'solid' | 'dash' | 'dot';
opacity: number;
fill: boolean;
label?: string;
}
/** A vector overlay a plugin draws on the trip map (routes, corridors, zones). */
export interface PluginMapLayer {
pluginId: string; id: string; name?: string;
features: PluginMapLayerFeature[];
}
/** A time contribution a dayScheduleProvider plugin attaches to the day plan
* ("35 min charging at this stop"). Server-normalized: dayIds checked against
* the trip, minutes clamped to a day, labels sanitized + capped. */
export interface PluginDayScheduleItem {
pluginId: string; id: string; dayId: number;
assignmentId?: number; reservationId?: number;
position?: 'start' | 'end';
minutes?: number; label: string;
tone: 'default' | 'success' | 'warn' | 'danger';
}
/** The colours a dayTintProvider plugin puts into one day card, so leg membership is
* visible while scrolling the itinerary. The card has three separately tintable
* regions; an absent one is not tinted and renders exactly as it does with no plugin.
* Server-normalized: dayIds checked against the trip, one contribution per day (first
* granted provider wins), the `tone` / `color` shorthands already resolved into the
* regions, labels sanitized + capped.
*
* A region carries EITHER a tone from the fixed palette or the plugin's own colour,
* never both — the server picked the winner. `*Color` is guaranteed `#rrggbb` (nothing
* else survives normalization, because it lands inside a CSS value); the client still
* owns how strongly it renders — alpha per theme and per region, lightness clamped
* into a readable band. */
export type PluginDayTintTone = 'default' | 'success' | 'warn' | 'danger'
export interface PluginDayTint {
pluginId: string; dayId: number;
badgeTone?: PluginDayTintTone;
badgeColor?: string;
headerTone?: PluginDayTintTone;
headerColor?: string;
activityTone?: PluginDayTintTone;
activityColor?: string;
label?: string;
}
/** A route computed by a routeProvider plugin (server-normalized: coordinates
* range-checked, legs forced to waypoints-1, vias capped). null = provider failed
* or refused — the caller falls back to straight lines like on an OSRM outage. */
export interface PluginRouteResult {
pluginId: string; profile: string;
coordinates: Array<[number, number]>;
distance: number; duration: number;
legs: Array<{ distance: number; duration: number; note?: string }>;
viaPoints: Array<{ lat: number; lng: number; label?: string; tone: 'default' | 'success' | 'warn' | 'danger'; dwellSeconds?: number }>;
}
/** A text-only section a pdfSectionProvider plugin appends to the trip PDF export.
* Server-normalized: counts + lengths are capped, cells are plain strings. */
export interface PluginPdfSection {
pluginId: string; title: string; paragraphs: string[];
table?: { headers: string[]; rows: string[][] }
}
/** A country tint layer an atlasLayerProvider plugin draws over the Atlas map for
* the signed-in user. Codes are ISO alpha-2 (server-validated), tone enum-whitelisted. */
export interface PluginAtlasLayer {
pluginId: string; id: string; name?: string;
countries: Array<{ code: string; tone: 'default' | 'success' | 'warn' | 'danger'; label?: string }>
}
export interface PluginUserSettingField {
key: string; label?: string | null; input_type?: string; placeholder?: string | null;
hint?: string | null; required?: boolean; secret?: boolean;
options?: Array<{ value: string; label: string }>
}
/** A button a plugin contributes to its own settings page ("Test connection"). */
export interface PluginAction {
key: string; label: string; hint?: string; danger: boolean
}
export const pluginsApi = {
// Active plugins the client renders (page nav entries, dashboard widgets).
active: () => apiClient.get('/plugins').then(r => r.data),
// Extra place info contributed by placeDetailProvider plugins (#1429). Fail-safe:
// the server skips any slow/failing provider, so this only ever adds rows.
placeDetails: (placeId: number) =>
apiClient.get(`/place-details/${placeId}`).then(r => r.data as { providers: Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }> }),
// Validation/warning contributions from warningProvider plugins (#1429). Fail-safe.
tripWarnings: (tripId: number) =>
apiClient.get(`/trip-warnings/${tripId}`).then(r => r.data as { warnings: Array<{ pluginId: string; level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number }> }),
// Host-rendered columns/actions plugins add into a native planner view via the
// tableContributor hook. Fetched once per view, keyed by entityId; fail-safe.
viewContributions: (view: 'reservations' | 'transports' | 'places' | 'day' | 'costs' | 'packing' | 'files' | 'todos', tripId: number | string) =>
apiClient.get(`/view-contributions/${view}/${tripId}`).then(r => r.data as { contributions: ViewContribution[] }),
// Bounded markers plugins overlay on the trip map via the mapMarkerProvider hook
// (#587). Host-normalized + range-checked; fail-safe (skips slow/failing providers).
mapMarkers: (tripId: number | string) =>
apiClient.get(`/map-markers/${tripId}`).then(r => r.data as { markers: PluginMapMarker[] }),
// Vector overlays (polylines/polygons/circles) plugins draw on the trip map via
// the mapLayerProvider hook. Host-normalized + vertex-budgeted; fail-safe.
mapLayers: (tripId: number | string) =>
apiClient.get(`/map-layers/${tripId}`).then(r => r.data as { layers: PluginMapLayer[] }),
// Route the given waypoints through ONE routeProvider plugin profile (targeted,
// not a fan-out — the user picked this profile in the route toggle). Slow by
// design (external solvers): the server allows the plugin 20 s.
pluginRoute: (pluginId: string, profileId: string, body: { tripId: number | string; dayId?: number | null; waypoints: Array<{ lat: number; lng: number; name?: string; placeId?: number }> }, opts: { signal?: AbortSignal } = {}) =>
apiClient.post(`/plugin-routes/${pluginId}/${profileId}`, body, { timeout: 25000, signal: opts.signal }).then(r => r.data as { route: PluginRouteResult | null }),
// Time contributions plugins attach to the day plan via the dayScheduleProvider
// hook (charging stops, security buffers). Host-normalized; fail-safe.
daySchedule: (tripId: number | string) =>
apiClient.get(`/day-schedule/${tripId}`).then(r => r.data as { items: PluginDayScheduleItem[] }),
// Per-day colours plugins put behind the day cards via the dayTintProvider hook
// (which leg of the trip a day belongs to). Host-normalized; fail-safe.
dayTints: (tripId: number | string) =>
apiClient.get(`/day-tints/${tripId}`).then(r => r.data as { tints: PluginDayTint[] }),
// Text-only sections plugins append to the trip PDF export via the
// pdfSectionProvider hook. Host-normalized (counts + lengths capped); fail-safe.
pdfSections: (tripId: number | string) =>
apiClient.get(`/pdf-sections/${tripId}`).then(r => r.data as { sections: PluginPdfSection[] }),
// Country tint layers plugins draw over the Atlas map for the signed-in user via
// the atlasLayerProvider hook. No tripId — user-scoped server-side; fail-safe.
atlasLayers: () =>
apiClient.get('/atlas-layers').then(r => r.data as { layers: PluginAtlasLayer[] }),
// Extra rows plugins add under a journal entry via the journalEntryProvider hook.
// Same shape + hardening as placeDetails (label/value/allowlisted url); fail-safe.
journalEntryRows: (entryId: number) =>
apiClient.get(`/journal-entry-rows/${entryId}`).then(r => r.data as { providers: Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }> }),
// Badges plugins add to the dashboard trip cards via the tripCardProvider hook.
// One call for all visible cards; host access-checks each tripId + bounds every
// field (label/value/tone/allowlisted url); fail-safe.
tripCardContributions: (tripIds: Array<number | string>) =>
apiClient.get(`/trip-card-contributions?tripIds=${tripIds.join(',')}`).then(r => r.data as { contributions: TripCardBadge[] }),
// The signed-in user's OWN plugin activity log — every host-mediated action a
// plugin took bound to them, across all plugins, newest first. The user-facing
// half of the capability audit; what makes the broad read grants accountable.
myActivity: (limit = 200) =>
apiClient.get(`/plugin-activity?limit=${limit}`).then(r => r.data as { activity: Array<{ ts: string; plugin_id: string; plugin_name: string | null; method: string; resource: string | null; code: string }> }),
// A user's OWN scope:'user' settings for a plugin (API key, prefs). Secrets are
// masked; the write only accepts declared user-scope keys.
userSettings: (id: string) =>
apiClient.get(`/plugin-settings/${id}`).then(r => r.data as {
fields: PluginUserSettingField[]
config: Record<string, unknown>
actions: PluginAction[]
}),
// Run a settings-page action the plugin declared ("Test connection"). It runs AS the
// caller, so it reads the caller's own settings.
runAction: (id: string, key: string) =>
apiClient.post(`/plugin-settings/${id}/actions/${encodeURIComponent(key)}`)
.then(r => r.data as { ok: boolean; message?: string }),
saveUserSettings: (id: string, config: Record<string, unknown>) =>
apiClient.post(`/plugin-settings/${id}`, { config }).then(r => r.data as { config: Record<string, unknown> }),
// Host-brokered outbound OAuth (the host owns the tokens; the plugin only triggers).
oauthStatus: (id: string) =>
apiClient.get(`/plugin-oauth/${id}/status`).then(r => r.data as { configured: boolean; connected: boolean }),
oauthConnect: (id: string) =>
apiClient.post(`/plugin-oauth/${id}/connect`).then(r => r.data as { authorizeUrl: string }),
oauthDisconnect: (id: string) =>
apiClient.post(`/plugin-oauth/${id}/disconnect`).then(r => r.data as { connected: boolean }),
// Call one of a plugin's own declared routes through the host proxy. `sub` is
// supplied by untrusted plugin code (the trekBridge forwards it verbatim), so it
// MUST stay inside the plugin's own /plugins/:id/ namespace. We resolve it with
// the URL parser — which normalizes `../`, encoded traversal and backslashes the
// same way the browser would before sending — and reject anything that escapes
// the prefix or points off-origin. Without this a plugin could send
// sub='/../../auth/me' and drive arbitrary authenticated /api routes as the user.
invoke: (id: string, sub: string, init?: { method?: string; body?: unknown }) => {
const prefix = `/api/plugins/${id}/`
let resolved: URL
try {
resolved = new URL(String(sub).replace(/^\/+/, ''), window.location.origin + prefix)
} catch {
return Promise.reject(new Error('invalid plugin route'))
}
if (resolved.origin !== window.location.origin || !resolved.pathname.startsWith(prefix)) {
return Promise.reject(new Error('plugin route escapes its namespace'))
}
const url = resolved.pathname.slice('/api'.length) + resolved.search
return apiClient.request({ url, method: init?.method || 'GET', data: init?.body }).then(r => r.data)
},
}
export const airtrailApi = {
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean; writeEnabled?: boolean }) =>
@@ -498,8 +905,8 @@ export const airtrailApi = {
sync: (): Promise<{ changed: number }> => apiClient.post('/integrations/airtrail/sync').then(r => r.data),
// flights + import are added with the trip-planner import (P2)
flights: () => apiClient.get('/integrations/airtrail/flights').then(r => r.data),
import: (tripId: number, flightIds: string[]) =>
apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, { flightIds }).then(r => r.data),
import: (tripId: number, flightIds: string[], connections?: string[][]) =>
apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, connections?.length ? { flightIds, connections } : { flightIds }).then(r => r.data),
}
export const journeyApi = {
@@ -518,29 +925,37 @@ export const journeyApi = {
// Entries
listEntries: (id: number) => apiClient.get(`/journeys/${id}/entries`).then(r => r.data),
// GPX tracks of the trips this journey's entries came from (#1260).
listTracks: (id: number): Promise<JourneyTracksResponse> => apiClient.get(`/journeys/${id}/tracks`).then(r => r.data),
// What the journey adds up to — distance, days, countries, the route (#1973).
// Read by TREK Studio, which freezes the answer into the book document.
stats: (id: number): Promise<JourneyStats> => apiClient.get(`/journeys/${id}/stats`).then(r => r.data),
// The Studio book (#1973). `book` is null for a journey that has none yet —
// Studio lays one out and the first save creates it.
getBook: (id: number): Promise<{ book: BookRecord | null }> =>
apiClient.get(`/journeys/${id}/book`).then(r => r.data),
// A 409 carries the current record in its body, so a conflict can be shown
// rather than only announced. See useBookStore.
saveBook: (id: number, body: BookSaveRequest): Promise<BookRecord> =>
apiClient.put(`/journeys/${id}/book`, body).then(r => r.data),
deleteBook: (id: number): Promise<void> =>
apiClient.delete(`/journeys/${id}/book`).then(r => r.data),
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds } satisfies JourneyReorderEntriesRequest).then(r => r.data),
// Photos
uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
apiClient.post(`/journeys/entries/${entryId}/photos`, formData, {
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
timeout: 0,
onUploadProgress: opts?.onUploadProgress,
signal: opts?.signal,
}).then(r => r.data),
uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, {
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
timeout: 0,
onUploadProgress: opts?.onUploadProgress,
signal: opts?.signal,
}).then(r => r.data),
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
uploadPhotos: (entryId: number, formData: FormData, opts?: UploadOptions) =>
postMultipart(`/journeys/entries/${entryId}/photos`, formData, opts),
uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: UploadOptions) =>
postMultipart(`/journeys/${journeyId}/gallery/photos`, formData, opts),
uploadGalleryVideo: (journeyId: number, formData: FormData, opts?: UploadOptions) =>
postMultipart(`/journeys/${journeyId}/gallery/video`, formData, opts),
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) }).then(r => r.data),
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
@@ -548,7 +963,7 @@ export const journeyApi = {
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
// Cover
uploadCover: (id: number, formData: FormData) => apiClient.post(`/journeys/${id}/cover`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
uploadCover: (id: number, formData: FormData) => postMultipart(`/journeys/${id}/cover`, formData),
// Contributors
addContributor: (id: number, userId: number, role: string) => apiClient.post(`/journeys/${id}/contributors`, { user_id: userId, role }).then(r => r.data),
@@ -565,19 +980,58 @@ export const journeyApi = {
getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data),
}
// Photo providers (Immich, Synology Photos, …) behind /api/integrations/memories.
// The provider sits on the user's own hardware and the server proxies through to
// it, so the 8s default does not apply — and neither does any number picked
// here. The server already holds the deadline, and its worst case is longer than
// anything that would look reasonable in this file: a Synology call is 30s, and
// an expired session makes it re-authenticate and try again. A client cap below
// that turns a slow NAS into a NAS that reads as disconnected, which is what
// these calls looked like before they moved off raw fetch (which had no timeout
// either). Callers that need to give up early pass an AbortSignal.
const MEMORIES_TIMEOUT = 0
export const memoriesApi = {
status: (provider: string): Promise<{ connected: boolean }> =>
apiClient.get(`/integrations/memories/${provider}/status`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data),
search: (provider: string, body: { from: string; to: string; page: number; size: number }, signal?: AbortSignal) =>
apiClient.post(`/integrations/memories/${provider}/search`, body, { timeout: MEMORIES_TIMEOUT, signal }).then(r => r.data),
albums: (provider: string) =>
apiClient.get(`/integrations/memories/${provider}/albums`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data),
albumPhotos: (provider: string, albumId: string, passphrase?: string, signal?: AbortSignal) =>
apiClient.get(`/integrations/memories/${provider}/albums/${albumId}/photos`, {
timeout: MEMORIES_TIMEOUT,
params: passphrase ? { passphrase } : undefined,
signal,
}).then(r => r.data),
}
export const mapsApi = {
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
details: (placeId: string, lang?: string, sessionToken?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
// Pictures and a description for a place that is being looked at but not yet
// saved. Fans out to several providers server-side, so it takes a signal and
// the caller is expected to abort it when the selection changes, and a longer
// timeout than the global 8s — a cold Wikimedia connection alone can eat that.
placeEnrichment: (
body: { placeId?: string; lat: number; lng: number; name: string; lang?: string; details?: Record<string, unknown> },
signal?: AbortSignal,
) => apiClient.post('/maps/enrichment', body, { signal, timeout: 25000 })
.then(r => checkInDev(mapsPlaceEnrichmentResultSchema, r.data, 'maps.placeEnrichment')),
// Author + licence for a picture already stored in the photo cache, keyed by
// the cache key embedded in its proxy URL. Local read, no provider call.
placePhotoCredit: (key: string) =>
apiClient.get(`/maps/enrichment/credit/${encodeURIComponent(key)}`).then(r => r.data as { credit: string | null }),
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => checkInDev(mapsPlacePhotoResultSchema, r.data, 'maps.placePhoto')),
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
// OSM-only POI explore: places of a category within the current map viewport bbox.
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
// timeout than the global default instead of aborting at 8s and showing nothing.
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, lang?: string, signal?: AbortSignal) =>
apiClient.get('/maps/pois', { params: { category, ...bbox, lang }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
}
export const airportsApi = {
@@ -595,8 +1049,8 @@ export const budgetApi = {
setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data),
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
@@ -604,9 +1058,7 @@ export const budgetApi = {
export const filesApi = {
list: (tripId: number | string, trash?: boolean) => apiClient.get(`/trips/${tripId}/files`, { params: trash ? { trash: 'true' } : {} }).then(r => r.data),
upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
}).then(r => r.data),
upload: (tripId: number | string, formData: FormData, opts?: UploadOptions) => postMultipart(`/trips/${tripId}/files`, formData, opts),
update: (tripId: number | string, id: number, data: FileUpdateRequest) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data),
toggleStar: (tripId: number | string, id: number) => apiClient.patch(`/trips/${tripId}/files/${id}/star`).then(r => r.data),
@@ -624,22 +1076,40 @@ export const reservationsApi = {
create: (tripId: number | string, data: ReservationCreateRequest) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
// Assign trip members / named guests to a booking (#1517).
setTravelers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/reservations/${id}/travelers`, { user_ids: userIds }).then(r => r.data),
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
importBookingPreview: (tripId: number | string, files: File[]): Promise<BookingImportPreviewResponse> => {
importBookingPreview: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<BookingImportPreviewResponse> => {
const fd = new FormData()
for (const f of files) fd.append('files', f)
return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
fd.append('mode', mode)
// No client-side timeout: kitinerary + LLM extraction routinely exceeds the
// global 8s default (a cold local model alone can take ~45s).
return postMultipart(`/trips/${tripId}/reservations/import/booking`, fd)
},
importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise<BookingImportConfirmResponse> =>
apiClient.post(`/trips/${tripId}/reservations/import/booking/confirm`, { items }).then(r => r.data),
// Start a background parse: returns a job id at once; progress + result arrive
// over the WebSocket (import:progress / import:done / import:error).
importBookingAsync: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<{ jobId: string }> => {
const fd = new FormData()
for (const f of files) fd.append('files', f)
fd.append('mode', mode)
return postMultipart(`/trips/${tripId}/reservations/import/booking/async`, fd)
},
// Poll a background job — recovery path when a WebSocket push was missed.
importJobStatus: (tripId: number | string, jobId: string): Promise<{ status: 'running' | 'done' | 'error'; done: number; total: number; result?: BookingImportPreviewResponse; error?: string }> =>
apiClient.get(`/trips/${tripId}/reservations/import/jobs/${jobId}`).then(r => r.data),
}
export const healthApi = {
features: (): Promise<{ bookingImport: boolean }> => apiClient.get('/health/features').then(r => r.data),
features: (): Promise<{ bookingImport: boolean; aiParsing: boolean }> => apiClient.get('/health/features').then(r => r.data),
}
export const weatherApi = {
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
// `time` (HH:MM) makes a past date answer for that hour instead of the day (#1614).
get: (lat: number, lng: number, date: string, time?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date, time } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
getCurrent: (lat: number, lng: number, lang?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getCurrent')),
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')),
}
@@ -648,6 +1118,17 @@ export const configApi = {
apiClient.get('/config').then(r => r.data),
}
export interface HelpNavItem { title: string; slug: string }
export interface HelpNavSection { title: string; pages: HelpNavItem[] }
export interface HelpPageData { slug: string; title: string; markdown: string }
export const helpApi = {
index: (): Promise<{ sections: HelpNavSection[] }> =>
apiClient.get('/help/index').then(r => r.data),
page: (slug: string): Promise<HelpPageData> =>
apiClient.get(`/help/page/${encodeURIComponent(slug)}`).then(r => r.data),
}
export const settingsApi = {
get: () => apiClient.get('/settings').then(r => r.data),
set: (key: string, value: unknown) => {
@@ -679,7 +1160,7 @@ export const collabApi = {
createNote: (tripId: number | string, data: CollabNoteCreateRequest) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
updateNote: (tripId: number | string, id: number, data: CollabNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => postMultipart(`/trips/${tripId}/collab/notes/${noteId}/files`, formData),
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
createPoll: (tripId: number | string, data: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
@@ -701,20 +1182,14 @@ export const backupApi = {
credentials: 'include',
})
if (!res.ok) throw new Error('Download failed')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
downloadBlob(await res.blob(), filename)
},
delete: (filename: string) => apiClient.delete(`/backup/${filename}`).then(r => r.data),
restore: (filename: string) => apiClient.post(`/backup/restore/${filename}`).then(r => r.data),
uploadRestore: (file: File) => {
const form = new FormData()
form.append('backup', file)
return apiClient.post('/backup/upload-restore', form, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
return postMultipart('/backup/upload-restore', form)
},
getAutoSettings: () => apiClient.get('/backup/auto-settings').then(r => r.data),
setAutoSettings: (settings: Record<string, unknown>) => apiClient.put('/backup/auto-settings', settings).then(r => r.data),
@@ -727,12 +1202,34 @@ export const shareApi = {
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
}
// Public transit routing (#1065) — Transitous/MOTIS proxied through the server.
export const transitApi = {
geocode: (q: string, opts?: { lang?: string; near?: string }) =>
apiClient.get('/transit/geocode', { params: { q, lang: opts?.lang, near: opts?.near } }).then(r => r.data),
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number }) =>
apiClient.get('/transit/plan', { params }).then(r => r.data),
}
// Trip invite links (#1143) — join a trip as an existing, logged-in user.
export const tripInviteApi = {
getLink: (tripId: number | string) => apiClient.get(`/trips/${tripId}/invite-link`).then(r => r.data),
createLink: (tripId: number | string, expires_in_days?: number | null) =>
apiClient.post(`/trips/${tripId}/invite-link`, { expires_in_days: expires_in_days ?? null }).then(r => r.data),
deleteLink: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/invite-link`).then(r => r.data),
preview: (token: string) => apiClient.get(`/trip-invites/${token}`).then(r => r.data),
accept: (token: string) => apiClient.post(`/trip-invites/${token}/accept`).then(r => r.data),
}
export const notificationsApi = {
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testSmtp')),
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testWebhook')),
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testNtfy')),
// Generic channel test — this is how a PLUGIN channel's "Send test" button works.
testChannel: (channelId: string) =>
apiClient.post(`/notifications/test/${encodeURIComponent(channelId)}`)
.then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testChannel')),
}
export const inAppNotificationsApi = {
@@ -754,4 +1251,4 @@ export const inAppNotificationsApi = {
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
}
export default apiClient
export default apiClient
+342
View File
@@ -0,0 +1,342 @@
// FE-API-COLLECTIONS-001 to FE-API-COLLECTIONS-032
//
// The Collections addon wrapper is thin, but every method encodes a URL, a verb and a
// request-body shape that the server contract depends on. These tests drive each method
// through MSW and pin the method + path + payload, plus the unwrapping of `r.data`.
import { describe, it, expect, beforeEach } from 'vitest'
import { http, HttpResponse, type JsonBodyType } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import { collectionsApi } from './collections'
import type { Collection, CollectionLabel, CollectionPlace } from '@trek/shared'
const BASE = '/api/addons/collections'
const collection: Collection = { id: 1, owner_id: 1, name: 'Tokyo', place_count: 2, is_owner: true }
const place: CollectionPlace = { id: 10, collection_id: 1, name: 'Shibuya Crossing', status: 'want' }
const label: CollectionLabel = { id: 3, collection_id: 1, name: 'Food', color: '#ef4444' }
let requestUrl = ''
let requestBody: unknown
beforeEach(() => {
requestUrl = ''
requestBody = undefined
})
/** Records url + parsed JSON body of the intercepted request, then answers with `data`. */
function record<T extends JsonBodyType>(data: T) {
return async ({ request }: { request: Request }) => {
requestUrl = request.url
const text = await request.text()
if (text) {
try {
requestBody = JSON.parse(text)
} catch {
requestBody = text
}
}
return HttpResponse.json(data)
}
}
describe('collectionsApi', () => {
it('FE-API-COLLECTIONS-001: list() unwraps the collections + incomingInvites envelope', async () => {
server.use(http.get(BASE, record({ collections: [collection], incomingInvites: [] })))
const res = await collectionsApi.list()
expect(res.collections).toEqual([collection])
expect(res.incomingInvites).toEqual([])
})
it('FE-API-COLLECTIONS-002: get() requests the list by id', async () => {
server.use(http.get(`${BASE}/:id`, record({ collection, places: [place] })))
const res = await collectionsApi.get(1)
expect(requestUrl).toContain(`${BASE}/1`)
expect(res.places).toEqual([place])
expect(res.collection.name).toBe('Tokyo')
})
it('FE-API-COLLECTIONS-003: create() posts the create payload', async () => {
server.use(http.post(BASE, record({ collection })))
const res = await collectionsApi.create({ name: 'Tokyo', color: '#111827' })
expect(requestBody).toEqual({ name: 'Tokyo', color: '#111827' })
expect(res.collection.id).toBe(1)
})
it('FE-API-COLLECTIONS-004: update() patches the list by id', async () => {
server.use(http.patch(`${BASE}/:id`, record({ collection })))
const res = await collectionsApi.update(1, { name: 'Tokyo 2026' })
expect(requestUrl).toContain(`${BASE}/1`)
expect(requestBody).toEqual({ name: 'Tokyo 2026' })
expect(res.collection).toEqual(collection)
})
it('FE-API-COLLECTIONS-005: uploadCover() posts multipart to the cover endpoint', async () => {
server.use(http.post(`${BASE}/:id/cover`, record(collection)))
const fd = new FormData()
fd.append('cover', new File(['x'], 'cover.jpg'))
const res = await collectionsApi.uploadCover(1, fd)
expect(requestUrl).toContain(`${BASE}/1/cover`)
expect(res).toEqual(collection)
})
it('FE-API-COLLECTIONS-006: remove() deletes the list', async () => {
server.use(http.delete(`${BASE}/:id`, record({ success: true })))
const res = await collectionsApi.remove(4)
expect(requestUrl).toContain(`${BASE}/4`)
expect(res).toEqual({ success: true })
})
it('FE-API-COLLECTIONS-007: reorder() posts the ordered ids', async () => {
server.use(http.post(`${BASE}/reorder`, record({ success: true })))
await collectionsApi.reorder([3, 1, 2])
expect(requestBody).toEqual({ orderedIds: [3, 1, 2] })
})
it('FE-API-COLLECTIONS-008: savePlace() posts the place payload', async () => {
server.use(http.post(`${BASE}/places`, record({ place })))
const res = await collectionsApi.savePlace({ collection_id: 1, name: 'Shibuya Crossing', force: true })
expect(requestBody).toEqual({ collection_id: 1, name: 'Shibuya Crossing', force: true })
expect(res.place).toEqual(place)
})
it('FE-API-COLLECTIONS-009: saveFromTrip() posts the provenance-only payload', async () => {
server.use(http.post(`${BASE}/places/from-trip`, record({ duplicate: true, duplicateOf: { id: 9, name: 'Shibuya' } })))
const res = await collectionsApi.saveFromTrip({ collection_id: 1, source_trip_id: 7, source_place_id: 42 })
expect(requestBody).toEqual({ collection_id: 1, source_trip_id: 7, source_place_id: 42 })
expect(res.duplicate).toBe(true)
})
it('FE-API-COLLECTIONS-010: saveFromTripMany() maps its arguments onto the bulk payload', async () => {
server.use(http.post(`${BASE}/places/from-trip-many`, record({ copied: 2, skipped: [] })))
const res = await collectionsApi.saveFromTripMany(1, 7, [11, 12], true)
expect(requestBody).toEqual({ collection_id: 1, source_trip_id: 7, source_place_ids: [11, 12], force: true })
expect(res.copied).toBe(2)
})
it('FE-API-COLLECTIONS-011: updatePlace() patches the place and returns it unwrapped', async () => {
server.use(http.patch(`${BASE}/places/:pid`, record({ ...place, notes: 'busy at night' })))
const res = await collectionsApi.updatePlace(10, { notes: 'busy at night' })
expect(requestUrl).toContain(`${BASE}/places/10`)
expect(requestBody).toEqual({ notes: 'busy at night' })
expect(res.notes).toBe('busy at night')
})
it('FE-API-COLLECTIONS-012: uploadPlaceImage() posts multipart to the place image endpoint', async () => {
server.use(http.post(`${BASE}/places/:pid/image`, record({ ...place, image_url: '/uploads/p.jpg' })))
const fd = new FormData()
fd.append('image', new File(['x'], 'p.jpg'))
const res = await collectionsApi.uploadPlaceImage(10, fd)
expect(requestUrl).toContain(`${BASE}/places/10/image`)
expect(res.image_url).toBe('/uploads/p.jpg')
})
it('FE-API-COLLECTIONS-013: setStatus() posts the status', async () => {
server.use(http.post(`${BASE}/places/:pid/status`, record({ ...place, status: 'visited' })))
const res = await collectionsApi.setStatus(10, 'visited')
expect(requestUrl).toContain(`${BASE}/places/10/status`)
expect(requestBody).toEqual({ status: 'visited' })
expect(res.status).toBe('visited')
})
it('FE-API-COLLECTIONS-014: ratePlace() PUTs a numeric rating', async () => {
server.use(http.put(`${BASE}/places/:pid/rating`, record({ ...place, rating_avg: 4 })))
const res = await collectionsApi.ratePlace(10, 4)
expect(requestUrl).toContain(`${BASE}/places/10/rating`)
expect(requestBody).toEqual({ rating: 4 })
expect(res.rating_avg).toBe(4)
})
it('FE-API-COLLECTIONS-015: ratePlace(null) DELETEs the rating instead', async () => {
let deleted = false
server.use(
http.put(`${BASE}/places/:pid/rating`, () => HttpResponse.json({ error: 'should not be called' }, { status: 500 })),
http.delete(`${BASE}/places/:pid/rating`, () => {
deleted = true
return HttpResponse.json({ ...place, rating_avg: null })
}),
)
const res = await collectionsApi.ratePlace(10, null)
expect(deleted).toBe(true)
expect(res.rating_avg).toBeNull()
})
it('FE-API-COLLECTIONS-016: deletePlace() deletes the saved place', async () => {
server.use(http.delete(`${BASE}/places/:pid`, record({ success: true })))
await collectionsApi.deletePlace(10)
expect(requestUrl).toContain(`${BASE}/places/10`)
})
it('FE-API-COLLECTIONS-017: deleteMany() posts the id list', async () => {
server.use(http.post(`${BASE}/places/delete-many`, record({ deleted: 2 })))
const res = await collectionsApi.deleteMany([10, 11])
expect(requestBody).toEqual({ ids: [10, 11] })
expect(res).toEqual({ deleted: 2 })
})
it('FE-API-COLLECTIONS-018: copyToTrip() posts the copy payload and returns the dedup report', async () => {
server.use(http.post(`${BASE}/copy-to-trip`, record({ copied: 1, skipped: [{ id: 11, name: 'Shibuya' }] })))
const res = await collectionsApi.copyToTrip({ trip_id: 7, place_ids: [10, 11] })
expect(requestBody).toEqual({ trip_id: 7, place_ids: [10, 11] })
expect(res.copied).toBe(1)
expect(res.skipped).toEqual([{ id: 11, name: 'Shibuya' }])
})
it('FE-API-COLLECTIONS-019: membership() sends the lookup as query params', async () => {
server.use(http.get(`${BASE}/membership`, record({ saved: true, lists: [{ collection_id: 1, name: 'Tokyo', place_id: 10 }] })))
const res = await collectionsApi.membership({ google_place_id: 'g1', lat: 35.6, lng: 139.7 })
const params = new URL(requestUrl).searchParams
expect(params.get('google_place_id')).toBe('g1')
expect(params.get('lat')).toBe('35.6')
expect(params.get('lng')).toBe('139.7')
expect(res.saved).toBe(true)
})
it('FE-API-COLLECTIONS-020: invite() posts collection_id, user_id and role', async () => {
server.use(http.post(`${BASE}/invite`, record({ success: true })))
await collectionsApi.invite(1, 5, 'admin')
expect(requestBody).toEqual({ collection_id: 1, user_id: 5, role: 'admin' })
})
it('FE-API-COLLECTIONS-021: setMemberRole() posts the new role', async () => {
server.use(http.post(`${BASE}/members/role`, record({ success: true })))
await collectionsApi.setMemberRole(1, 5, 'viewer')
expect(requestBody).toEqual({ collection_id: 1, user_id: 5, role: 'viewer' })
})
it('FE-API-COLLECTIONS-022: acceptInvite() posts only the collection id', async () => {
server.use(http.post(`${BASE}/invite/accept`, record({ success: true })))
await collectionsApi.acceptInvite(1)
expect(requestBody).toEqual({ collection_id: 1 })
})
it('FE-API-COLLECTIONS-023: declineInvite() posts only the collection id', async () => {
server.use(http.post(`${BASE}/invite/decline`, record({ success: true })))
await collectionsApi.declineInvite(2)
expect(requestBody).toEqual({ collection_id: 2 })
})
it('FE-API-COLLECTIONS-024: cancelInvite() posts collection_id and user_id', async () => {
server.use(http.post(`${BASE}/invite/cancel`, record({ success: true })))
await collectionsApi.cancelInvite(1, 5)
expect(requestBody).toEqual({ collection_id: 1, user_id: 5 })
})
it('FE-API-COLLECTIONS-025: leave() posts the collection id', async () => {
server.use(http.post(`${BASE}/leave`, record({ success: true })))
await collectionsApi.leave(3)
expect(requestBody).toEqual({ collection_id: 3 })
})
it('FE-API-COLLECTIONS-026: removeMember() posts collection_id and user_id', async () => {
server.use(http.post(`${BASE}/members/remove`, record({ success: true })))
await collectionsApi.removeMember(1, 9)
expect(requestBody).toEqual({ collection_id: 1, user_id: 9 })
})
it('FE-API-COLLECTIONS-027: availableUsers() reads the invitable users for a list', async () => {
server.use(http.get(`${BASE}/:id/available-users`, record({ users: [{ id: 5, username: 'bob' }] })))
const res = await collectionsApi.availableUsers(1)
expect(requestUrl).toContain(`${BASE}/1/available-users`)
expect(res.users).toEqual([{ id: 5, username: 'bob' }])
})
it('FE-API-COLLECTIONS-028: createLabel() posts collection_id, name and color', async () => {
server.use(http.post(`${BASE}/labels`, record(label)))
const res = await collectionsApi.createLabel(1, 'Food', '#ef4444')
expect(requestBody).toEqual({ collection_id: 1, name: 'Food', color: '#ef4444' })
expect(res).toEqual(label)
})
it('FE-API-COLLECTIONS-029: updateLabel() patches the label by id', async () => {
server.use(http.patch(`${BASE}/labels/:id`, record({ ...label, name: 'Eats' })))
const res = await collectionsApi.updateLabel(3, { name: 'Eats' })
expect(requestUrl).toContain(`${BASE}/labels/3`)
expect(requestBody).toEqual({ name: 'Eats' })
expect(res.name).toBe('Eats')
})
it('FE-API-COLLECTIONS-030: deleteLabel() deletes the label by id', async () => {
server.use(http.delete(`${BASE}/labels/:id`, record({ success: true })))
await collectionsApi.deleteLabel(3)
expect(requestUrl).toContain(`${BASE}/labels/3`)
})
it('FE-API-COLLECTIONS-031: assignLabels() posts label_ids and place_ids', async () => {
server.use(http.post(`${BASE}/labels/assign`, record({ changed: 2 })))
const res = await collectionsApi.assignLabels([3], [10, 11])
expect(requestBody).toEqual({ label_ids: [3], place_ids: [10, 11] })
expect(res.changed).toBe(2)
})
it('FE-API-COLLECTIONS-032: unassignLabels() posts to the unassign endpoint', async () => {
server.use(http.post(`${BASE}/labels/unassign`, record({ changed: 1 })))
const res = await collectionsApi.unassignLabels([3], [10])
expect(requestUrl).toContain(`${BASE}/labels/unassign`)
expect(requestBody).toEqual({ label_ids: [3], place_ids: [10] })
expect(res.changed).toBe(1)
})
})
+126
View File
@@ -0,0 +1,126 @@
import apiClient, { postMultipart } from './client'
import type { AxiosResponse } from 'axios'
import type {
CollectionListResponse,
CollectionDetailResponse,
CollectionSaveResult,
CollectionMembership,
CollectionCreateRequest,
CollectionUpdateRequest,
CollectionSavePlaceRequest,
CollectionSaveFromTripRequest,
CollectionImportablesResponse,
CollectionPlaceUpdateRequest,
CollectionCopyToTripRequest,
CollectionInviteRequest,
CollectionRole,
CollectionInviteActionRequest,
CollectionInviteCancelRequest,
CollectionStatus,
CollectionSetStatusManyResponse,
Collection,
CollectionPlace,
CollectionLabel,
CollectionLabelCreateRequest,
CollectionLabelUpdateRequest,
} from '@trek/shared'
const ax = apiClient
const base = '/addons/collections'
/** Query for the library-wide "is this place already saved?" lookup. */
export interface MembershipQuery {
google_place_id?: string
google_ftid?: string
name?: string
lat?: number
lng?: number
}
export interface CopyToTripResult {
copied: number
skipped: { id: number; name: string }[]
}
/**
* Axios calls for the Collections addon (/api/addons/collections). Mirrors the
* vacayStore api shape — each method returns the unwrapped response body and
* uses `satisfies` on the request payloads so the shared Zod request types stay
* the single source of truth.
*/
export const collectionsApi = {
list: (): Promise<CollectionListResponse> =>
ax.get(base).then((r: AxiosResponse) => r.data),
get: (id: number): Promise<CollectionDetailResponse> =>
ax.get(`${base}/${id}`).then((r: AxiosResponse) => r.data),
create: (body: CollectionCreateRequest): Promise<{ collection: Collection }> =>
ax.post(base, body satisfies CollectionCreateRequest).then((r: AxiosResponse) => r.data),
update: (id: number, body: CollectionUpdateRequest): Promise<{ collection: Collection }> =>
ax.patch(`${base}/${id}`, body satisfies CollectionUpdateRequest).then((r: AxiosResponse) => r.data),
uploadCover: (id: number, formData: FormData): Promise<Collection> =>
postMultipart(`${base}/${id}/cover`, formData),
remove: (id: number): Promise<unknown> =>
ax.delete(`${base}/${id}`).then((r: AxiosResponse) => r.data),
reorder: (orderedIds: number[]): Promise<unknown> =>
ax.post(`${base}/reorder`, { orderedIds }).then((r: AxiosResponse) => r.data),
savePlace: (body: CollectionSavePlaceRequest): Promise<CollectionSaveResult> =>
ax.post(`${base}/places`, body satisfies CollectionSavePlaceRequest).then((r: AxiosResponse) => r.data),
saveFromTrip: (body: CollectionSaveFromTripRequest): Promise<CollectionSaveResult> =>
ax.post(`${base}/places/from-trip`, body satisfies CollectionSaveFromTripRequest).then((r: AxiosResponse) => r.data),
saveFromTripMany: (collectionId: number, tripId: number, placeIds: number[], force?: boolean): Promise<{ copied: number; skipped: { id: number; name: string }[] }> =>
ax.post(`${base}/places/from-trip-many`, { collection_id: collectionId, source_trip_id: tripId, source_place_ids: placeIds, force }).then((r: AxiosResponse) => r.data),
importable: (collectionId: number, tripId: number): Promise<CollectionImportablesResponse> =>
ax.get(`${base}/${collectionId}/importable/${tripId}`).then((r: AxiosResponse) => r.data),
updatePlace: (pid: number, body: CollectionPlaceUpdateRequest): Promise<CollectionPlace> =>
ax.patch(`${base}/places/${pid}`, body satisfies CollectionPlaceUpdateRequest).then((r: AxiosResponse) => r.data),
uploadPlaceImage: (pid: number, formData: FormData): Promise<CollectionPlace> =>
postMultipart(`${base}/places/${pid}/image`, formData),
setStatus: (pid: number, status: CollectionStatus): Promise<CollectionPlace> =>
ax.post(`${base}/places/${pid}/status`, { status }).then((r: AxiosResponse) => r.data),
setStatusMany: (ids: number[], status: CollectionStatus): Promise<CollectionSetStatusManyResponse> =>
ax.post(`${base}/places/status-many`, { ids, status }).then((r: AxiosResponse) => r.data),
setStatusFromTrip: (tripId: number, placeIds: number[], status: CollectionStatus): Promise<CollectionSetStatusManyResponse> =>
ax.post(`${base}/places/status-from-trip`, { trip_id: tripId, place_ids: placeIds, status }).then((r: AxiosResponse) => r.data),
ratePlace: (pid: number, rating: number | null): Promise<CollectionPlace> =>
rating === null
? ax.delete(`${base}/places/${pid}/rating`).then((r: AxiosResponse) => r.data)
: ax.put(`${base}/places/${pid}/rating`, { rating }).then((r: AxiosResponse) => r.data),
deletePlace: (pid: number): Promise<unknown> =>
ax.delete(`${base}/places/${pid}`).then((r: AxiosResponse) => r.data),
deleteMany: (ids: number[]): Promise<unknown> =>
ax.post(`${base}/places/delete-many`, { ids }).then((r: AxiosResponse) => r.data),
copyToTrip: (body: CollectionCopyToTripRequest): Promise<CopyToTripResult> =>
ax.post(`${base}/copy-to-trip`, body satisfies CollectionCopyToTripRequest).then((r: AxiosResponse) => r.data),
membership: (params: MembershipQuery): Promise<CollectionMembership> =>
ax.get(`${base}/membership`, { params }).then((r: AxiosResponse) => r.data),
invite: (collectionId: number, userId: number, role?: CollectionRole): Promise<unknown> =>
ax.post(`${base}/invite`, { collection_id: collectionId, user_id: userId, role } satisfies CollectionInviteRequest).then((r: AxiosResponse) => r.data),
setMemberRole: (collectionId: number, userId: number, role: CollectionRole): Promise<unknown> =>
ax.post(`${base}/members/role`, { collection_id: collectionId, user_id: userId, role }).then((r: AxiosResponse) => r.data),
acceptInvite: (collectionId: number): Promise<unknown> =>
ax.post(`${base}/invite/accept`, { collection_id: collectionId } satisfies CollectionInviteActionRequest).then((r: AxiosResponse) => r.data),
declineInvite: (collectionId: number): Promise<unknown> =>
ax.post(`${base}/invite/decline`, { collection_id: collectionId } satisfies CollectionInviteActionRequest).then((r: AxiosResponse) => r.data),
cancelInvite: (collectionId: number, userId: number): Promise<unknown> =>
ax.post(`${base}/invite/cancel`, { collection_id: collectionId, user_id: userId } satisfies CollectionInviteCancelRequest).then((r: AxiosResponse) => r.data),
leave: (collectionId: number): Promise<unknown> =>
ax.post(`${base}/leave`, { collection_id: collectionId }).then((r: AxiosResponse) => r.data),
removeMember: (collectionId: number, userId: number): Promise<unknown> =>
ax.post(`${base}/members/remove`, { collection_id: collectionId, user_id: userId }).then((r: AxiosResponse) => r.data),
availableUsers: (id: number): Promise<{ users: { id: number; username: string }[] }> =>
ax.get(`${base}/${id}/available-users`).then((r: AxiosResponse) => r.data),
createLabel: (collectionId: number, name: string, color?: string): Promise<CollectionLabel> =>
ax.post(`${base}/labels`, { collection_id: collectionId, name, color } satisfies CollectionLabelCreateRequest).then((r: AxiosResponse) => r.data),
updateLabel: (labelId: number, body: CollectionLabelUpdateRequest): Promise<CollectionLabel> =>
ax.patch(`${base}/labels/${labelId}`, body satisfies CollectionLabelUpdateRequest).then((r: AxiosResponse) => r.data),
deleteLabel: (labelId: number): Promise<unknown> =>
ax.delete(`${base}/labels/${labelId}`).then((r: AxiosResponse) => r.data),
assignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> =>
ax.post(`${base}/labels/assign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data),
unassignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> =>
ax.post(`${base}/labels/unassign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data),
}
+51 -4
View File
@@ -1,12 +1,26 @@
// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-010
// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-014
import { describe, it, expect } from 'vitest'
import { SCOPE_GROUPS, ALL_SCOPES, SCOPE_GROUP_NAMES, getScopesByGroup } from './oauthScopes'
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { SCOPE_GROUPS, ALL_SCOPES, SCOPE_GROUP_NAMES, getScopesByGroup, PRESET_OPT_IN_ONLY, PRESET_SCOPES_DEFAULT, PRESET_SCOPES_READONLY } from './oauthScopes'
// The consent page mirrors the server's scope list by hand, so read the server
// file and compare. Parsing keeps this a client-only test (no server import).
function readServerScopes(): string[] {
const here = dirname(fileURLToPath(import.meta.url))
const src = readFileSync(resolve(here, '../../../server/src/mcp/scopes.ts'), 'utf8')
const block = src.slice(src.indexOf('export const SCOPES = {'))
const body = block.slice(0, block.indexOf('} as const;'))
return [...body.matchAll(/^\s*[A-Z_]+:\s*'([a-z]+:[a-z]+)'/gm)].map(m => m[1])
}
describe('SCOPE_GROUPS', () => {
it('FE-OAUTH-SCOPES-001: contains all expected scope keys', () => {
const expected = [
'trips:read', 'trips:write', 'trips:delete', 'trips:share',
'places:read', 'places:write',
'collections:read', 'collections:write',
'atlas:read', 'atlas:write',
'packing:read', 'packing:write',
'todos:read', 'todos:write',
@@ -16,6 +30,8 @@ describe('SCOPE_GROUPS', () => {
'notifications:read', 'notifications:write',
'vacay:read', 'vacay:write',
'geo:read', 'weather:read',
'journey:read', 'journey:write', 'journey:share',
'plugins:use',
]
for (const scope of expected) {
expect(SCOPE_GROUPS).toHaveProperty(scope)
@@ -32,8 +48,16 @@ describe('SCOPE_GROUPS', () => {
})
describe('ALL_SCOPES', () => {
it('FE-OAUTH-SCOPES-003: contains exactly 27 scopes', () => {
expect(ALL_SCOPES).toHaveLength(27)
it('FE-OAUTH-SCOPES-003: contains exactly as many scopes as the server defines', () => {
expect(ALL_SCOPES).toHaveLength(readServerScopes().length)
})
it('FE-OAUTH-SCOPES-011: matches server/src/mcp/scopes.ts exactly', () => {
const serverScopes = readServerScopes()
// Guards the parse itself: a moved file or a reformatted list must fail
// here rather than quietly compare two empty sets.
expect(serverScopes.length).toBeGreaterThan(0)
expect(new Set(ALL_SCOPES)).toEqual(new Set(serverScopes))
})
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
@@ -100,3 +124,26 @@ describe('getScopesByGroup', () => {
expect(groups['oauth.scope.group.trips']).toBeUndefined()
})
})
describe('client presets', () => {
// The presets are written as "everything except deletes", so a new scope joins
// them silently. That is right for a scope over the user's own data and wrong
// for one that runs third-party code, so the exclusion gets a test.
it('FE-OAUTH-SCOPES-012: never offers an opt-in-only scope by default', () => {
expect(PRESET_OPT_IN_ONLY.size).toBeGreaterThan(0)
for (const scope of PRESET_OPT_IN_ONLY) {
expect(PRESET_SCOPES_DEFAULT).not.toContain(scope)
expect(PRESET_SCOPES_READONLY).not.toContain(scope)
}
})
it('FE-OAUTH-SCOPES-013: still offers every other non-destructive scope', () => {
const expected = ALL_SCOPES.filter(s => !s.includes(':delete') && !PRESET_OPT_IN_ONLY.has(s))
expect(PRESET_SCOPES_DEFAULT).toEqual(expected)
expect(PRESET_SCOPES_DEFAULT).toContain('trips:write')
})
it('FE-OAUTH-SCOPES-014: the read-only preset stays read-only', () => {
expect(PRESET_SCOPES_READONLY.every(s => s.endsWith(':read'))).toBe(true)
})
})
+24
View File
@@ -20,6 +20,8 @@ export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
'trips:share': { labelKey: 'oauth.scope.trips:share.label', descriptionKey: 'oauth.scope.trips:share.description', groupKey: 'oauth.scope.group.trips' },
'places:read': { labelKey: 'oauth.scope.places:read.label', descriptionKey: 'oauth.scope.places:read.description', groupKey: 'oauth.scope.group.places' },
'places:write': { labelKey: 'oauth.scope.places:write.label', descriptionKey: 'oauth.scope.places:write.description', groupKey: 'oauth.scope.group.places' },
'collections:read': { labelKey: 'oauth.scope.collections:read.label', descriptionKey: 'oauth.scope.collections:read.description', groupKey: 'oauth.scope.group.collections' },
'collections:write': { labelKey: 'oauth.scope.collections:write.label', descriptionKey: 'oauth.scope.collections:write.description', groupKey: 'oauth.scope.group.collections' },
'atlas:read': { labelKey: 'oauth.scope.atlas:read.label', descriptionKey: 'oauth.scope.atlas:read.description', groupKey: 'oauth.scope.group.atlas' },
'atlas:write': { labelKey: 'oauth.scope.atlas:write.label', descriptionKey: 'oauth.scope.atlas:write.description', groupKey: 'oauth.scope.group.atlas' },
'packing:read': { labelKey: 'oauth.scope.packing:read.label', descriptionKey: 'oauth.scope.packing:read.description', groupKey: 'oauth.scope.group.packing' },
@@ -41,10 +43,32 @@ export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
'journey:read': { labelKey: 'oauth.scope.journey:read.label', descriptionKey: 'oauth.scope.journey:read.description', groupKey: 'oauth.scope.group.journey' },
'journey:write': { labelKey: 'oauth.scope.journey:write.label', descriptionKey: 'oauth.scope.journey:write.description', groupKey: 'oauth.scope.group.journey' },
'journey:share': { labelKey: 'oauth.scope.journey:share.label', descriptionKey: 'oauth.scope.journey:share.description', groupKey: 'oauth.scope.group.journey' },
'files:read': { labelKey: 'oauth.scope.files:read.label', descriptionKey: 'oauth.scope.files:read.description', groupKey: 'oauth.scope.group.files' },
'files:write': { labelKey: 'oauth.scope.files:write.label', descriptionKey: 'oauth.scope.files:write.description', groupKey: 'oauth.scope.group.files' },
'files:content': { labelKey: 'oauth.scope.files:content.label', descriptionKey: 'oauth.scope.files:content.description', groupKey: 'oauth.scope.group.files' },
'settings:read': { labelKey: 'oauth.scope.settings:read.label', descriptionKey: 'oauth.scope.settings:read.description', groupKey: 'oauth.scope.group.settings' },
'settings:write': { labelKey: 'oauth.scope.settings:write.label', descriptionKey: 'oauth.scope.settings:write.description', groupKey: 'oauth.scope.group.settings' },
'plugins:use': { labelKey: 'oauth.scope.plugins:use.label', descriptionKey: 'oauth.scope.plugins:use.description', groupKey: 'oauth.scope.group.plugins' },
}
export const ALL_SCOPES = Object.keys(SCOPE_GROUPS)
/**
* Scopes a client preset must never tick for you.
*
* The presets are written as "everything except deletes", so a new scope joins
* them silently. That is right for a new read or write scope on data the user
* already owns, and wrong for plugins:use: it turns on third-party tool
* execution, which nobody would have chosen by installing an editor.
*/
export const PRESET_OPT_IN_ONLY = new Set(['plugins:use'])
/** The five full-access presets: everything destructive or opt-in-only removed. */
export const PRESET_SCOPES_DEFAULT = ALL_SCOPES.filter(s => !s.includes(':delete') && !PRESET_OPT_IN_ONLY.has(s))
/** The read-only preset (VS Code). Kept beside the default so the pair stays visible. */
export const PRESET_SCOPES_READONLY = ALL_SCOPES.filter(s => s.endsWith(':read') && !PRESET_OPT_IN_ONLY.has(s))
// Group all scopes for the client registration form
export const SCOPE_GROUP_NAMES = [...new Set(Object.values(SCOPE_GROUPS).map(s => s.groupKey))]
+75
View File
@@ -0,0 +1,75 @@
// FE-API-UPLOAD-001 to FE-API-UPLOAD-013
//
// The shared axios instance carries timeout: 8000, and axios' timeout is a whole-request
// deadline — not an idle one. Any upload whose body takes longer than 8s to push is
// aborted mid-stream and the server reports a multer "Request aborted" (#1495).
//
// The original fix added `timeout: 0` to the three cover uploads by hand, which left the
// same bug live on 7 other endpoints — including the two that accept 500 MB (documents
// and backup restore). Every multipart call now goes through postMultipart(), so this
// suite pins ALL of them, not just the covers.
import { describe, it, expect, vi, afterEach } from 'vitest'
import {
apiClient,
authApi,
tripsApi,
placesApi,
adminApi,
journeyApi,
filesApi,
reservationsApi,
collabApi,
backupApi,
} from './client'
import { collectionsApi } from './collections'
describe('every multipart upload disables the global request timeout', () => {
afterEach(() => {
vi.restoreAllMocks()
})
function spyPost() {
return vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} } as any)
}
const fd = () => new FormData()
const file = () => new File(['x'], 'f.bin')
// [id, description, invoke, expected url]
const cases: [string, string, () => Promise<unknown>, string][] = [
['FE-API-UPLOAD-001', 'authApi.uploadAvatar (5 MB)', () => authApi.uploadAvatar(fd()), '/auth/avatar'],
['FE-API-UPLOAD-002', 'tripsApi.uploadCover (20 MB)', () => tripsApi.uploadCover(7, fd()), '/trips/7/cover'],
['FE-API-UPLOAD-003', 'placesApi.importGpx (10 MB)', () => placesApi.importGpx(7, file()), '/trips/7/places/import/gpx'],
['FE-API-UPLOAD-004', 'placesApi.importMapFile (10 MB)', () => placesApi.importMapFile(7, file()), '/trips/7/places/import/map'],
['FE-API-UPLOAD-005', 'adminApi.pluginUpload (50 MB)', () => adminApi.pluginUpload(file()), '/admin/plugins/upload'],
['FE-API-UPLOAD-006', 'journeyApi.uploadCover (20 MB)', () => journeyApi.uploadCover(7, fd()), '/journeys/7/cover'],
['FE-API-UPLOAD-007', 'journeyApi.uploadPhotos (20 MB)', () => journeyApi.uploadPhotos(7, fd()), '/journeys/entries/7/photos'],
['FE-API-UPLOAD-008', 'journeyApi.uploadGalleryVideo (500 MB)', () => journeyApi.uploadGalleryVideo(7, fd()), '/journeys/7/gallery/video'],
['FE-API-UPLOAD-009', 'filesApi.upload (500 MB)', () => filesApi.upload(7, fd()), '/trips/7/files'],
['FE-API-UPLOAD-010', 'collabApi.uploadNoteFile (50 MB)', () => collabApi.uploadNoteFile(7, 3, fd()), '/trips/7/collab/notes/3/files'],
['FE-API-UPLOAD-011', 'backupApi.uploadRestore (500 MB)', () => backupApi.uploadRestore(file()), '/backup/upload-restore'],
['FE-API-UPLOAD-012', 'collectionsApi.uploadCover (20 MB)', () => collectionsApi.uploadCover(7, fd()), '/addons/collections/7/cover'],
]
for (const [id, desc, invoke, url] of cases) {
it(`${id}: ${desc} posts with timeout 0`, async () => {
const post = spyPost()
await invoke()
expect(post).toHaveBeenCalledWith(
url,
expect.any(FormData),
expect.objectContaining({ timeout: 0 }),
)
})
}
it('FE-API-UPLOAD-013: reservationsApi booking import posts with timeout 0', async () => {
const post = spyPost()
await reservationsApi.importBookingPreview(7, [file()])
expect(post).toHaveBeenCalledWith(
'/trips/7/reservations/import/booking',
expect.any(FormData),
expect.objectContaining({ timeout: 0 }),
)
})
})
+267
View File
@@ -0,0 +1,267 @@
// vi.unmock must run before the module is imported (tests/setup.ts mocks it globally)
vi.unmock('./websocket')
// FE-WSCORE-001 to FE-WSCORE-014
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import {
connect, disconnect, joinTrip, leaveTrip, getActiveTrips,
setRefetchCallback, setPreReconnectHook,
} from './websocket'
class MockWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
static instances: MockWebSocket[] = []
readyState: number = MockWebSocket.OPEN
send = vi.fn((_data: string) => {})
close = vi.fn(() => {})
onopen: (() => void) | null = null
onmessage: ((event: { data: string }) => void) | null = null
onclose: (() => void) | null = null
onerror: (() => void) | null = null
constructor(public url: string) {
MockWebSocket.instances.push(this)
}
}
function lastSocket(): MockWebSocket {
return MockWebSocket.instances[MockWebSocket.instances.length - 1]
}
const realLocation = window.location
beforeEach(() => {
vi.useFakeTimers()
MockWebSocket.instances = []
Object.defineProperty(globalThis, 'WebSocket', {
writable: true, configurable: true, value: MockWebSocket,
})
server.use(http.post('/api/auth/ws-token', () => HttpResponse.json({ token: 'ws-tok' })))
})
afterEach(() => {
disconnect()
setRefetchCallback(null)
setPreReconnectHook(null)
vi.useRealTimers()
vi.restoreAllMocks()
Object.defineProperty(window, 'location', { writable: true, configurable: true, value: realLocation })
})
/** connect() + settle the token fetch so a socket exists. */
async function openSocket(): Promise<MockWebSocket> {
connect()
await vi.advanceTimersByTimeAsync(0)
return lastSocket()
}
describe('websocket > active trips', () => {
it('FE-WSCORE-001: getActiveTrips lists the joined trips as strings', async () => {
expect(getActiveTrips()).toEqual([])
joinTrip(42)
joinTrip('7')
expect(getActiveTrips()).toEqual(['42', '7'])
disconnect()
expect(getActiveTrips()).toEqual([])
})
it('FE-WSCORE-013: join/leave still bookkeep while no socket is open', () => {
joinTrip(5)
expect(getActiveTrips()).toEqual(['5'])
leaveTrip(5)
expect(getActiveTrips()).toEqual([])
})
it('FE-WSCORE-014: a trip joined before onopen is not re-sent while the socket is closing', async () => {
joinTrip(11)
const sock = await openSocket()
sock.readyState = MockWebSocket.CLOSING
sock.onopen!()
expect(sock.send).not.toHaveBeenCalled()
})
})
describe('websocket > reconnect refetch hook', () => {
it('FE-WSCORE-002: the pre-reconnect hook is awaited before the refetch runs', async () => {
const order: string[] = []
setPreReconnectHook(async () => { order.push('flush') })
setRefetchCallback(() => { order.push('refetch') })
joinTrip(3)
const sock = await openSocket()
sock.onopen!()
await vi.advanceTimersByTimeAsync(0)
expect(order).toEqual(['flush', 'refetch'])
})
it('FE-WSCORE-003: a rejecting pre-reconnect hook still lets the refetch run', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const refetch = vi.fn((_tripId: string) => {})
setPreReconnectHook(async () => { throw new Error('queue flush failed') })
setRefetchCallback(refetch)
joinTrip(3)
const sock = await openSocket()
sock.onopen!()
await vi.advanceTimersByTimeAsync(0)
expect(refetch).toHaveBeenCalledWith('3')
expect(consoleError).toHaveBeenCalled()
})
it('FE-WSCORE-004: a throwing refetch callback is logged, not propagated', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
setRefetchCallback(() => { throw new Error('store blew up') })
joinTrip(3)
const sock = await openSocket()
expect(() => sock.onopen!()).not.toThrow()
expect(consoleError).toHaveBeenCalledWith(
'Failed to refetch trip data on reconnect:',
expect.any(Error),
)
})
it('FE-WSCORE-005: with no joined trips onopen sends nothing and skips the refetch', async () => {
const refetch = vi.fn((_tripId: string) => {})
setRefetchCallback(refetch)
const sock = await openSocket()
sock.onopen!()
expect(sock.send).not.toHaveBeenCalled()
expect(refetch).not.toHaveBeenCalled()
})
})
describe('websocket > connection lifecycle', () => {
it('FE-WSCORE-006: connect() is a no-op while a socket is still CONNECTING', async () => {
const sock = await openSocket()
sock.readyState = MockWebSocket.CONNECTING
connect()
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(1)
})
it('FE-WSCORE-007: connect() cancels a pending reconnect timer', async () => {
server.use(http.post('/api/auth/ws-token', () => new HttpResponse(null, { status: 503 })))
connect()
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(0)
// A retry is now armed; connect() must clear it and dial immediately.
server.use(http.post('/api/auth/ws-token', () => HttpResponse.json({ token: 'fresh' })))
connect()
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(1)
// The cancelled timer must not fire a second dial afterwards.
await vi.advanceTimersByTimeAsync(5000)
expect(MockWebSocket.instances).toHaveLength(1)
})
it('FE-WSCORE-008: a duplicate close does not stack a second timer or skip a backoff step', async () => {
const sock = await openSocket()
// Every further token fetch fails, so each retry attempt is countable.
let attempts = 0
server.use(http.post('/api/auth/ws-token', () => {
attempts++
return new HttpResponse(null, { status: 503 })
}))
// A browser can deliver close twice (after onerror); the second must be ignored.
sock.onclose!()
sock.onclose!()
await vi.advanceTimersByTimeAsync(1001)
await vi.advanceTimersByTimeAsync(0)
expect(attempts, 'first retry fires after the 1s delay').toBe(1)
// Backoff advanced once (1s → 2s), not twice, so the next retry lands at 2s.
await vi.advanceTimersByTimeAsync(2001)
await vi.advanceTimersByTimeAsync(0)
expect(attempts, 'second retry fires after the doubled 2s delay').toBe(2)
expect(MockWebSocket.instances).toHaveLength(1)
})
it('FE-WSCORE-009: a failing ws-token fetch schedules a retry instead of throwing', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('offline'))
connect()
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(0)
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(JSON.stringify({ token: 'back-online' }), {
status: 200, headers: { 'Content-Type': 'application/json' },
}),
)
await vi.advanceTimersByTimeAsync(1001)
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(1)
expect(lastSocket().url).toContain('token=back-online')
})
it('FE-WSCORE-010: the socket URL uses ws:// on http and wss:// on https', async () => {
const httpSock = await openSocket()
expect(httpSock.url.startsWith('ws://')).toBe(true)
disconnect()
MockWebSocket.instances = []
Object.defineProperty(window, 'location', {
writable: true, configurable: true,
value: {
protocol: 'https:',
host: 'trip.example',
origin: 'https://trip.example',
href: 'https://trip.example/dashboard',
pathname: '/dashboard',
},
})
const secure = await openSocket()
expect(secure.url).toBe('wss://trip.example/ws?token=ws-tok')
})
it('FE-WSCORE-011: disconnect() detaches onclose so no reconnect is armed', async () => {
const sock = await openSocket()
disconnect()
expect(sock.onclose).toBeNull()
expect(sock.close).toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(5000)
expect(MockWebSocket.instances).toHaveLength(1)
})
it('FE-WSCORE-012: onerror is inert — the reconnect is driven by onclose', async () => {
const sock = await openSocket()
expect(() => sock.onerror!()).not.toThrow()
expect(MockWebSocket.instances).toHaveLength(1)
sock.onclose!()
await vi.advanceTimersByTimeAsync(1001)
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(2)
})
})
+49
View File
@@ -9,6 +9,15 @@ let reconnectDelay = 1000
const MAX_RECONNECT_DELAY = 30000
const listeners = new Set<WebSocketListener>()
const activeTrips = new Set<string>()
/**
* Studio books this tab has open, so presence survives a reconnect.
*
* Same reasoning as `activeTrips`: the server forgets the room when the socket
* dies, and without rejoining, everyone else keeps showing a pointer for
* somebody who is no longer there.
*/
const activeBooks = new Set<string>()
let shouldReconnect = false
let refetchCallback: RefetchCallback | null = null
let mySocketId: string | null = null
@@ -110,6 +119,11 @@ async function connectInternal(_isReconnect = false): Promise<void> {
socket.onopen = () => {
reconnectDelay = 1000
activeBooks.forEach(journeyId => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'book:join', journeyId }))
}
})
if (activeTrips.size > 0) {
activeTrips.forEach(tripId => {
if (socket && socket.readyState === WebSocket.OPEN) {
@@ -166,6 +180,7 @@ export function disconnect(): void {
reconnectTimer = null
}
activeTrips.clear()
activeBooks.clear()
if (socket) {
socket.onclose = null
socket.close()
@@ -187,6 +202,40 @@ export function leaveTrip(tripId: number | string): void {
}
}
/**
* Open a Studio book — presence and pointers, for as long as the editor is up.
*/
export function joinBook(journeyId: number | string): void {
activeBooks.add(String(journeyId))
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'book:join', journeyId: String(journeyId) }))
}
}
export function leaveBook(journeyId: number | string): void {
activeBooks.delete(String(journeyId))
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'book:leave', journeyId: String(journeyId) }))
}
}
/**
* Where this tab's pointer is, in millimetres on the spread.
*
* Dropped silently when the socket is not open. A pointer is worth nothing a
* moment later, so there is nothing to queue and nothing to retry — the next
* one is along in a tenth of a second.
*/
export function sendBookCursor(
journeyId: number | string,
spreadIndex: number,
x: number | null,
y: number | null,
): void {
if (!socket || socket.readyState !== WebSocket.OPEN) return
socket.send(JSON.stringify({ type: 'book:cursor', journeyId: String(journeyId), spreadIndex, x, y }))
}
export function addListener(fn: WebSocketListener): void {
listeners.add(fn)
}
+104
View File
@@ -0,0 +1,104 @@
import type { TrekWsEventName } from '@trek/shared'
/**
* Client-side handling policy for every event in the shared WS registry
* (`TREK_WS_EVENTS` in @trek/shared). Together with the tripStore lookups
* (DEXIE_WRITERS / STATE_APPLIERS in store/slices/remoteEventHandler.ts),
* these lists partition the registry exactly — the registry-parity test
* fails if a registry event is missing from all of them, or listed twice.
* A new server event therefore forces an explicit client decision (handle
* it, or add it here) instead of being dropped by a silent `default:`.
*/
/**
* Events consumed by dedicated listeners outside the tripStore reducer.
* Every entry names real handling code — if that code is removed, remove
* the entry (the event then needs a new home or an IGNORED_WS_EVENTS slot).
*/
export const HANDLED_OUTSIDE_TRIP_STORE = [
// Collab — Collab/MCollab components + useTripWebSocket's collabFileSync
'collab:note:created',
'collab:note:updated',
'collab:note:deleted',
'collab:poll:created',
'collab:poll:voted',
'collab:poll:closed',
'collab:poll:deleted',
'collab:message:created',
'collab:message:reacted',
'collab:message:deleted',
// In-app notifications — hooks/useInAppNotificationListener
'notification:new',
'notification:updated',
// Collections — pages/collections/useCollections ('collections:' prefix listener)
'collections:updated',
'collections:accepted',
'collections:declined',
'collections:left',
'collections:deleted',
'collections:cancelled',
'collections:removed',
'collections:invite',
// Vacay — pages/vacay/useVacay
'vacay:update',
'vacay:settings',
'vacay:accepted',
'vacay:declined',
'vacay:cancelled',
'vacay:dissolved',
'vacay:invite',
'vacay:share',
'vacay:share-removed',
'vacay:shared-update',
// Journey — pages/journeyDetail/useJourneyDetail ('journey:' prefix listener)
'journey:trip:synced',
'journey:entry:created',
'journey:entry:updated',
'journey:entry:deleted',
'journey:entries:reordered',
'journey:contributor:changed',
// Studio book — components/Studio/useBookStore (its own listener: a client
// with nothing outstanding takes the new version, one with unsaved edits
// deliberately does not and conflicts on its next save instead)
'journey:book:saved',
// Studio presence — components/Studio/useBookPresence (its own listener:
// who has the book open, and where their pointers are)
'journey:book:peers',
'journey:book:cursor',
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
'import:progress',
'import:done',
'import:error',
] as const satisfies readonly TrekWsEventName[]
/**
* Events the client deliberately does not act on today (state of the world
* when the registry landed — every one of these was already dropped by the
* old silent `default:` branches). Removing an entry means the event is now
* handled somewhere; ADDING an entry is a product decision that a new server
* event should have no client reaction — never add one just to silence the
* registry-parity test.
*/
export const IGNORED_WS_EVENTS = [
'assignment:participants',
'packing:reordered',
'packing:bag-created',
'packing:bag-updated',
'packing:bag-deleted',
'packing:bag-members-updated',
'packing:assignees',
'packing:template-applied',
'todo:assignees',
'budget:settlement-created',
'budget:settlement-updated',
'budget:settlement-deleted',
'reservation:positions',
// Accommodations live in page-local planner state; the client refetches
// them off trip:updated date changes, never off these events.
'accommodation:created',
'accommodation:updated',
'accommodation:deleted',
'trip:deleted',
'member:added',
'member:removed',
] as const satisfies readonly TrekWsEventName[]
@@ -1,7 +1,7 @@
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-011
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-025
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { delay, http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
@@ -21,6 +21,45 @@ function buildAddon(overrides = {}) {
};
}
function addonsRoute(addons: ReturnType<typeof buildAddon>[]) {
return http.get('/api/admin/addons', () => HttpResponse.json({ addons }));
}
function llmAddon(config: Record<string, unknown> = {}) {
return buildAddon({
id: 'llm_parsing',
name: 'AI Parsing',
description: 'Extract bookings from files',
icon: 'Sparkles',
type: 'integration',
enabled: true,
config,
});
}
function modelsRoute(names: string[], seen?: (string | null)[]) {
return http.get('/api/admin/llm/local/models', ({ request }) => {
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
return HttpResponse.json({ models: names.map(name => ({ name, size: 1 })) });
});
}
/** The pill toggle of a top-level addon row. */
function addonToggle(name: string): HTMLElement {
const row = screen.getByText(name).closest('.px-6.py-4') as HTMLElement;
return within(row).getByRole('button');
}
/** The pill toggle of an indented sub-row (bag tracking, collab feature, photo provider). */
function subToggle(label: string): HTMLElement {
const row = screen.getByText(label).closest('.flex.items-center.gap-4') as HTMLElement;
return within(row).getByRole('button');
}
function isOn(toggle: HTMLElement): boolean {
return toggle.style.background === 'var(--text-primary)';
}
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
@@ -59,7 +98,7 @@ describe('AddonManager', () => {
it('FE-ADMIN-ADDON-002: empty state when addons list is empty', async () => {
render(<AddonManager />);
await screen.findByText('No addons available');
expect(await screen.findByText('No addons available')).toBeInTheDocument();
});
it('FE-ADMIN-ADDON-003: trip addons section renders with correct section header', async () => {
@@ -229,4 +268,360 @@ describe('AddonManager', () => {
expect(() => render(<AddonManager />)).not.toThrow();
await screen.findByText('Mystery Addon');
});
it('FE-ADMIN-ADDON-012: a failing load toasts the addon error and shows the empty state', async () => {
server.use(http.get('/api/admin/addons', () => HttpResponse.error()));
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Failed to update addon');
expect(screen.getByText('No addons available')).toBeInTheDocument();
});
it('FE-ADMIN-ADDON-013: dark mode swaps the wordmark in the header', async () => {
seedStore(useSettingsStore, { settings: { dark_mode: 'dark' } });
render(<AddonManager />);
await screen.findByText('No addons available');
expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-light.svg');
});
it('FE-ADMIN-ADDON-014: photo-flavoured trip addons are hidden from the trip section', async () => {
server.use(addonsRoute([
buildAddon({ id: 'photos', name: 'Memories', icon: 'Image' }),
buildAddon({ id: 'gallery', name: 'Trip Photos', icon: 'Puzzle', description: 'Share your photo stream' }),
buildAddon({ id: 'todo', name: 'Todo List' }),
]));
render(<AddonManager />);
await screen.findByText('Todo List');
expect(screen.queryByText('Memories')).not.toBeInTheDocument();
expect(screen.queryByText('Trip Photos')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-015: provider sub-rows carry their vendor icons and toggle state', async () => {
server.use(addonsRoute([
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: true }),
buildAddon({ id: 'synologyphotos', name: 'Synology Photos', description: 'NAS photos', type: 'photo_provider', enabled: false }),
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }),
]));
render(<AddonManager />);
await screen.findByText('Immich');
// immich and synologyphotos ship a vendor glyph, unsplash does not
const immichRow = screen.getByText('Immich').closest('.flex.items-center.gap-4') as HTMLElement;
expect(immichRow.querySelector('svg')).toBeInTheDocument();
const synologyRow = screen.getByText('Synology Photos').closest('.flex.items-center.gap-4') as HTMLElement;
expect(synologyRow.querySelector('svg')).toBeInTheDocument();
const unsplashRow = screen.getByText('Unsplash').closest('.flex.items-center.gap-4') as HTMLElement;
expect(unsplashRow.querySelector('svg')).not.toBeInTheDocument();
expect(isOn(subToggle('Immich'))).toBe(true);
expect(isOn(subToggle('Unsplash'))).toBe(false);
});
it('FE-ADMIN-ADDON-016: toggling a photo provider persists it and refreshes the global addons', async () => {
const user = userEvent.setup();
let body: unknown = null;
server.use(
addonsRoute([
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: false }),
]),
http.put('/api/admin/addons/immich', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Immich');
await user.click(subToggle('Immich'));
await waitFor(() => expect(body).toEqual({ enabled: true }));
await screen.findByText('Addon updated');
expect(isOn(subToggle('Immich'))).toBe(true);
});
it('FE-ADMIN-ADDON-017: a failing photo-provider toggle rolls the sub-row back', async () => {
const user = userEvent.setup();
server.use(
addonsRoute([
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: true }),
]),
http.put('/api/admin/addons/unsplash', () => HttpResponse.error()),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Unsplash');
await user.click(subToggle('Unsplash'));
await screen.findByText('Failed to update addon');
await waitFor(() => expect(isOn(subToggle('Unsplash'))).toBe(true));
});
it('FE-ADMIN-ADDON-018: the collab sub-features render their state and report the toggled key', async () => {
const user = userEvent.setup();
const onToggleCollabFeature = vi.fn();
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
render(
<AddonManager
collabFeatures={{ chat: true, notes: false, polls: false, whatsnext: true }}
onToggleCollabFeature={onToggleCollabFeature}
/>,
);
await screen.findByText('Chat');
expect(screen.getByText('Notes')).toBeInTheDocument();
expect(screen.getByText('Polls')).toBeInTheDocument();
expect(screen.getByText("What's Next")).toBeInTheDocument();
expect(isOn(subToggle('Chat'))).toBe(true);
expect(isOn(subToggle('Notes'))).toBe(false);
await user.click(subToggle('Polls'));
expect(onToggleCollabFeature).toHaveBeenCalledWith('polls');
});
it('FE-ADMIN-ADDON-019: collab sub-features stay hidden without the handler props', async () => {
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
render(<AddonManager />);
await screen.findByText('Collab');
expect(screen.queryByText('Polls')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => {
server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }]));
render(<AddonManager />);
await screen.findByText('AI Parsing');
expect(screen.getByText('Extract bookings from files')).toBeInTheDocument();
expect(screen.queryByText('Connection')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3:8b', 'llama3:8b'], urls));
render(<AddonManager />);
await screen.findByText('Installed on the server');
await screen.findByRole('button', { name: 'llama3:8b' });
expect(urls[0]).toBe('http://localhost:11434/v1');
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
// qwen3:8b is already installed, so the recommended row offers "Use" instead of "Pull"
await user.click(screen.getByRole('button', { name: 'Use' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
});
it('FE-ADMIN-ADDON-022: an unreachable Ollama shows the error and Refresh retries', async () => {
const user = userEvent.setup();
let calls = 0;
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
http.get('/api/admin/llm/local/models', () => {
calls += 1;
return calls === 1
? HttpResponse.json({ error: 'down' }, { status: 500 })
: HttpResponse.json({ models: [] });
}),
);
render(<AddonManager />);
await screen.findByText(/Request failed with status code 500/);
await user.click(screen.getByRole('button', { name: 'Refresh' }));
await screen.findByText('No models installed yet — pull one below.');
expect(calls).toBe(2);
});
it('FE-ADMIN-ADDON-023: switching providers swaps the base URL field, the model hint and the Ollama block', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'local', apiKey: '••••••••' })]), modelsRoute([], urls));
render(<AddonManager />);
await screen.findByText('Installed on the server');
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
// A hand-typed base URL is used for the next lookup on blur
await user.type(screen.getByPlaceholderText('http://localhost:11434/v1'), 'http://ollama.lan:11434/v1');
await user.tab();
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toBeInTheDocument();
expect(screen.getByPlaceholderText('gpt-4o')).toBeInTheDocument();
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument();
expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument();
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
});
it('FE-ADMIN-ADDON-024: pulling a model streams progress and then selects it', async () => {
const user = userEvent.setup();
let pulled: unknown = null;
let modelCalls = 0;
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
http.get('/api/admin/llm/local/models', () => {
modelCalls += 1;
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3:8b', size: 1 }] });
}),
http.post('/api/admin/llm/local/pull', async ({ request }) => {
pulled = await request.json();
await delay(150);
return new HttpResponse(
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
{ headers: { 'Content-Type': 'application/x-ndjson' } },
);
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('Pulling…');
expect(screen.getByText('starting…')).toBeInTheDocument();
await screen.findByText('Model pulled');
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3:8b' });
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
});
it('FE-ADMIN-ADDON-025: a failing pull surfaces the server error and saving reports both outcomes', async () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([llmAddon({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true })]),
modelsRoute([]),
http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error();
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('no disk space');
expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled();
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Saved');
expect(bodies[0]).toEqual({
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true },
});
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Failed to save');
});
it('FE-ADMIN-ADDON-026: model and API key are editable and their hints follow the provider', async () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
modelsRoute([]),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Installed on the server');
expect(screen.getByPlaceholderText('(often not required)')).toBeInTheDocument();
await user.type(screen.getByPlaceholderText('select or pull below'), ' mistral:7b ');
await user.type(screen.getByPlaceholderText('(often not required)'), 'sk-live');
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
expect(screen.getByPlaceholderText('sk-…')).toHaveValue('sk-live');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Saved');
// The model is trimmed before it is stored, the key is sent verbatim
expect(bodies[0]).toEqual({
config: { provider: 'openai', model: 'mistral:7b', baseUrl: '', apiKey: 'sk-live', multimodal: false },
});
});
it('FE-ADMIN-ADDON-029: switching to Anthropic clears a stale base URL before saving', async () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([llmAddon({ provider: 'local', model: '', baseUrl: 'http://ollama.lan:11434/v1', apiKey: '', multimodal: false })]),
modelsRoute([]),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Installed on the server');
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
await user.type(screen.getByPlaceholderText('claude-opus-4-8'), 'claude-haiku-4-5-20251001');
await user.type(screen.getByPlaceholderText('sk-…'), 'sk-ant-live');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Saved');
// The stale local base URL must not ride along to Anthropic — it would hijack the endpoint.
expect(bodies[0]).toEqual({
config: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', baseUrl: '', apiKey: 'sk-ant-live', multimodal: false },
});
});
it('FE-ADMIN-ADDON-027: an error frame in the pull stream aborts the pull and is reported', async () => {
const user = userEvent.setup();
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
modelsRoute([]),
http.post('/api/admin/llm/local/pull', () => new HttpResponse(
'{"status":"pulling manifest"}\n{"error":"manifest not found"}\n',
{ headers: { 'Content-Type': 'application/x-ndjson' } },
)),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('manifest not found');
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
expect(screen.queryByText('Pulling…')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-028: blurring the base URL under a cloud provider queries no local models', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'openai' })]), modelsRoute([], urls));
render(<AddonManager />);
await screen.findByText('Connection');
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
await user.type(screen.getByPlaceholderText('https://api.openai.com/v1'), 'https://proxy.local/v1');
await user.tab();
await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument());
expect(urls).toHaveLength(0);
});
});
+235 -9
View File
@@ -1,13 +1,14 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, type ComponentType } from 'react'
import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane } from 'lucide-react'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane,
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark,
}
function ImmichIcon({ size = 14 }: { size?: number }) {
@@ -26,7 +27,7 @@ function SynologyIcon({ size = 14 }: { size?: number }) {
)
}
const PROVIDER_ICONS: Record<string, React.FC<{ size?: number }>> = {
const PROVIDER_ICONS: Record<string, ComponentType<{ size?: number }>> = {
immich: ImmichIcon,
synologyphotos: SynologyIcon,
}
@@ -195,7 +196,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
<span className={`hidden sm:inline text-xs font-medium ${bagTrackingEnabled ? 'text-content' : 'text-content-faint'}`}>
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button onClick={onToggleBagTracking}
<button type="button" onClick={onToggleBagTracking}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
@@ -221,7 +222,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
<span className={`hidden sm:inline text-xs font-medium ${enabled ? 'text-content' : 'text-content-faint'}`}>
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button onClick={() => onToggleCollabFeature(feat.key)}
<button type="button" onClick={() => onToggleCollabFeature(feat.key)}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
@@ -268,7 +269,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
<span className={`hidden sm:inline text-xs font-medium ${provider.enabled ? 'text-content' : 'text-content-faint'}`}>
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
<button type="button"
onClick={provider.toggle}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
@@ -298,7 +299,12 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
</span>
</div>
{integrationAddons.map(addon => (
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
<div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{addon.id === 'llm_parsing' && addon.enabled && (
<LlmParsingConfig addon={addon} />
)}
</div>
))}
</div>
)}
@@ -309,6 +315,226 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
)
}
const MASKED = '••••••••'
const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'
/** Curated models the local extractor is tuned for, pullable via Ollama. The router drives
* one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
* automatically, so the Qwen3 family works without any tuning. A host only needs one. */
const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [
{ id: 'qwen3:8b', label: 'Qwen3 — 8B', note: 'Recommended · best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0', recommended: true, vision: false },
]
/**
* Instance-wide AI-parsing config. When set, applies to the whole instance and
* overrides per-user config (see server llmConfig.ts). The API key is masked on
* read; an unchanged mask is treated as a no-op by the server. For the local
* provider, it also lists installed Ollama models and can pull NuExtract models.
*/
function LlmParsingConfig({ addon }: { addon: Addon }) {
const toast = useToast()
const cfg = (addon.config ?? {}) as Record<string, unknown>
const [provider, setProvider] = useState<string>((cfg.provider as string) ?? 'local')
const [model, setModel] = useState<string>((cfg.model as string) ?? '')
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '')
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '')
const [saving, setSaving] = useState(false)
// Local-provider model management.
const [installed, setInstalled] = useState<string[]>([])
const [modelsErr, setModelsErr] = useState('')
const [loadingModels, setLoadingModels] = useState(false)
const [pulling, setPulling] = useState<string | null>(null)
const [pullPct, setPullPct] = useState(0)
const [pullStatus, setPullStatus] = useState('')
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL
const isInstalled = (id: string) => installed.some(n => n === id || n.startsWith(id + ':') || n.startsWith(id))
const loadModels = async () => {
if (provider !== 'local') return
setLoadingModels(true)
setModelsErr('')
try {
const res = await adminApi.llmLocalModels(effectiveUrl)
setInstalled(res.models.map(m => m.name))
} catch (e: unknown) {
setModelsErr(e instanceof Error ? e.message : 'Could not reach the local LLM server')
setInstalled([])
} finally {
setLoadingModels(false)
}
}
// Load installed models when the local provider is active.
useEffect(() => {
if (provider === 'local') loadModels()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider])
const pull = async (id: string) => {
if (pulling) return
setPulling(id)
setPullPct(0)
setPullStatus('starting…')
try {
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
if (p.error) throw new Error(p.error)
if (p.status) setPullStatus(p.status)
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100))
})
toast.success('Model pulled')
setModel(id)
await loadModels()
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'Pull failed')
} finally {
setPulling(null)
setPullPct(0)
setPullStatus('')
}
}
const save = async () => {
setSaving(true)
try {
// Send the masked sentinel unchanged so the server keeps the stored key.
await adminApi.updateAddon(addon.id, { config: { provider, model: model.trim(), baseUrl: provider === 'anthropic' ? '' : baseUrl.trim(), apiKey, multimodal: cfg.multimodal === true } })
toast.success('Saved')
} catch {
toast.error('Failed to save')
} finally {
setSaving(false)
}
}
const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-3 py-2 text-sm text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none'
const labelCls = 'mb-1.5 block text-xs font-medium text-content-secondary'
const sectionCls = 'text-[11px] font-semibold uppercase tracking-wide text-content-faint'
const providerOptions = [
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
]
return (
<div className="border-b border-edge-secondary bg-surface-secondary py-5 pr-6 pl-[70px]">
<div className="max-w-2xl space-y-6">
<p className="text-xs text-content-faint">
Set instance-wide config (applies to all users). Leave blank to let each user configure their own provider.
</p>
{/* Connection */}
<section className="space-y-3">
<div className={sectionCls}>Connection</div>
<div>
<span className={labelCls}>Provider</span>
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
</div>
{provider !== 'anthropic' && (
<label className="block">
<span className={labelCls}>Base URL</span>
<input type="url" autoComplete="off" className={fieldCls} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
</label>
)}
<label className="block">
<span className={labelCls}>API key</span>
<input type="password" className={fieldCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
</label>
{provider === 'anthropic' && (
<p className="text-xs text-content-faint">Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.</p>
)}
</section>
{/* Model */}
<section className="space-y-3">
<div className={sectionCls}>Model</div>
<label className="block">
<span className="sr-only">Model</span>
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
</label>
{/* Local model management (Ollama) */}
{provider === 'local' && (
<div className="space-y-3 rounded-lg border border-edge-secondary bg-surface p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-content-secondary">Installed on the server</span>
<button type="button" onClick={loadModels} disabled={loadingModels} className="text-xs text-content-muted underline disabled:opacity-60">
{loadingModels ? 'Loading…' : 'Refresh'}
</button>
</div>
{modelsErr && <p className="text-xs text-rose-600">{modelsErr}</p>}
{!modelsErr && installed.length === 0 && !loadingModels && (
<p className="text-xs text-content-faint">No models installed yet — pull one below.</p>
)}
{installed.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{installed.map(name => (
<button type="button"
key={name}
title={name}
onClick={() => setModel(name)}
className={`max-w-full truncate rounded-full border px-2.5 py-1 text-xs transition-colors ${model === name ? 'border-transparent bg-accent text-accent-text' : 'border-edge-secondary text-content-secondary hover:border-edge'}`}
>
{name}
</button>
))}
</div>
)}
<div className="border-t border-edge-secondary pt-3">
<div className="mb-2 text-xs font-medium text-content-secondary">Pull a recommended model</div>
<div className="space-y-1">
{RECOMMENDED_MODELS.map(m => {
const installedHere = isInstalled(m.id)
const isPulling = pulling === m.id
const active = model === m.id
return (
<div key={m.id} className={`flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors ${active ? 'border-edge-secondary bg-surface-secondary' : 'border-transparent'}`}>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm text-content">{m.label}</span>
{m.recommended && (
<span className="rounded-md bg-[rgba(16,185,129,0.15)] px-1.5 py-px text-[10px] font-semibold text-emerald-600">Recommended</span>
)}
</div>
<div className="text-xs text-content-faint">{m.note}</div>
{isPulling && (
<div className="mt-1.5">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
</div>
<div className="mt-0.5 text-[10px] text-content-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
</div>
)}
</div>
{installedHere ? (
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
{active ? 'Selected' : 'Use'}
</button>
) : (
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-text disabled:opacity-60">
{isPulling ? 'Pulling…' : 'Pull'}
</button>
)}
</div>
)
})}
</div>
</div>
</div>
)}
</section>
<button type="button" onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-text transition-opacity disabled:opacity-60">
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
)
}
interface AddonRowProps {
addon: Addon
onToggle: (addon: Addon) => void
@@ -364,7 +590,7 @@ function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statu
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
{!hideToggle && (
<button
<button type="button"
onClick={() => !isComingSoon && onToggle(addon)}
disabled={isComingSoon}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
@@ -49,7 +49,7 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-002: empty state rendered when no tokens', async () => {
render(<AdminMcpTokensPanel />);
await screen.findByText('No MCP tokens have been created yet');
expect(await screen.findByText('No MCP tokens have been created yet')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-003: token list renders correctly', async () => {
@@ -195,7 +195,7 @@ describe('AdminMcpTokensPanel', () => {
)
);
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('Failed to load tokens');
expect(await screen.findByText('Failed to load tokens')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-011: OAuth sessions loading spinner shown on mount', async () => {
@@ -216,7 +216,7 @@ describe('AdminMcpTokensPanel', () => {
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('No active OAuth sessions');
expect(await screen.findByText('No active OAuth sessions')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-013: OAuth sessions list renders with scopes', async () => {
@@ -318,6 +318,6 @@ describe('AdminMcpTokensPanel', () => {
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
const confirmBtn = deleteBtns.find(b => !b.title);
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
await screen.findByText('Failed to revoke session');
expect(await screen.findByText('Failed to revoke session')).toBeInTheDocument();
});
});
@@ -126,13 +126,13 @@ export default function AdminMcpTokensPanel() {
</span>
))}
{!expanded && hidden > 0 && (
<button onClick={() => toggleScopes(session.id)}
<button type="button" onClick={() => toggleScopes(session.id)}
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
+{hidden} more
</button>
)}
{expanded && hidden > 0 && (
<button onClick={() => toggleScopes(session.id)}
<button type="button" onClick={() => toggleScopes(session.id)}
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
show less
</button>
@@ -146,7 +146,7 @@ export default function AdminMcpTokensPanel() {
<span className="text-xs whitespace-nowrap text-right pt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{new Date(session.created_at).toLocaleDateString(locale)}
</span>
<button onClick={() => setRevokeConfirmId(session.id)}
<button type="button" onClick={() => setRevokeConfirmId(session.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
<Trash2 className="w-4 h-4" />
@@ -199,7 +199,7 @@ export default function AdminMcpTokensPanel() {
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
</span>
<button onClick={() => setDeleteConfirmId(token.id)}
<button type="button" onClick={() => setDeleteConfirmId(token.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
<Trash2 className="w-4 h-4" />
@@ -214,16 +214,21 @@ export default function AdminMcpTokensPanel() {
{/* Revoke OAuth session modal */}
{revokeConfirmId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
role="button" tabIndex={0} aria-label={t('common.close')}
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}
onKeyDown={e => {
if (e.target !== e.currentTarget) return
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRevokeConfirmId(null) }
}}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
<h3 className="text-base font-semibold text-content">{t('admin.oauthSessions.revokeTitle')}</h3>
<p className="text-sm text-content-secondary">{t('admin.oauthSessions.revokeMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setRevokeConfirmId(null)}
<button type="button" onClick={() => setRevokeConfirmId(null)}
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')}
</button>
<button onClick={() => handleRevoke(revokeConfirmId)}
<button type="button" onClick={() => handleRevoke(revokeConfirmId)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
{t('common.delete')}
</button>
@@ -235,16 +240,21 @@ export default function AdminMcpTokensPanel() {
{/* Delete MCP token modal */}
{deleteConfirmId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
role="button" tabIndex={0} aria-label={t('common.close')}
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}
onKeyDown={e => {
if (e.target !== e.currentTarget) return
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setDeleteConfirmId(null) }
}}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
<h3 className="text-base font-semibold text-content">{t('admin.mcpTokens.deleteTitle')}</h3>
<p className="text-sm text-content-secondary">{t('admin.mcpTokens.deleteMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setDeleteConfirmId(null)}
<button type="button" onClick={() => setDeleteConfirmId(null)}
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')}
</button>
<button onClick={() => handleDelete(deleteConfirmId)}
<button type="button" onClick={() => handleDelete(deleteConfirmId)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
{t('common.delete')}
</button>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -310,4 +310,292 @@ describe('BackupPanel', () => {
expect(screen.getByRole('button', { name: /^save$/i })).not.toBeDisabled()
})
})
// BKP-015: List request fails
it('FE-ADMIN-BKP-015: a failing list request toasts and keeps the empty state', async () => {
server.use(http.get('/api/backup/list', () => HttpResponse.error()))
render(<><ToastContainer /><BackupPanel /></>)
expect(await screen.findByText('Failed to load backups')).toBeInTheDocument()
expect(screen.getByText('No backups yet')).toBeInTheDocument()
})
// BKP-016: Create fails
it('FE-ADMIN-BKP-016: a failing create toasts the error and re-enables the button', async () => {
const user = userEvent.setup()
server.use(http.post('/api/backup/create', () => HttpResponse.error()))
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('backup-2025-01-15.zip')
await user.click(screen.getByTitle('Create Backup'))
expect(await screen.findByText('Failed to create backup')).toBeInTheDocument()
await waitFor(() => expect(screen.getByTitle('Create Backup')).toBeEnabled())
})
// BKP-017: Restore fails
it('FE-ADMIN-BKP-017: a failing restore surfaces the server message and clears the spinner', async () => {
const user = userEvent.setup()
server.use(
http.post('/api/backup/restore/:filename', () =>
HttpResponse.json({ error: 'archive is corrupt' }, { status: 400 }),
),
)
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('backup-2025-01-15.zip')
await user.click(screen.getAllByText('Restore')[0])
await user.click(await screen.findByText('Yes, restore'))
expect(await screen.findByText('archive is corrupt')).toBeInTheDocument()
await waitFor(() => expect(screen.getAllByText('Restore')[0].closest('button')).toBeEnabled())
})
// BKP-018: Upload & restore happy path
it('FE-ADMIN-BKP-018: picking a file opens the modal and uploads it on confirm', async () => {
const user = userEvent.setup()
let uploaded = false
server.use(
http.post('/api/backup/upload-restore', () => {
uploaded = true
return HttpResponse.json({ success: true })
}),
)
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('backup-2025-01-15.zip')
const reloadMock = vi.fn()
vi.stubGlobal('location', { ...window.location, reload: reloadMock })
const input = document.querySelector('input[type="file"]') as HTMLInputElement
await user.upload(input, new File(['zip'], 'restore-me.zip', { type: 'application/zip' }))
expect(await screen.findByText('Restore Backup?')).toBeInTheDocument()
expect(screen.getByText('restore-me.zip')).toBeInTheDocument()
// The picked file is cleared from the input so the same file can be chosen again
expect(input.value).toBe('')
await user.click(screen.getByText('Yes, restore'))
await waitFor(() => expect(uploaded).toBe(true))
expect(await screen.findByText('Backup restored. Page will reload…')).toBeInTheDocument()
vi.unstubAllGlobals()
})
// BKP-019: Upload & restore failure
it('FE-ADMIN-BKP-019: a failing upload restore toasts and re-enables the upload button', async () => {
const user = userEvent.setup()
server.use(
http.post('/api/backup/upload-restore', () => HttpResponse.json({ error: 'not a backup' }, { status: 400 })),
)
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('backup-2025-01-15.zip')
const input = document.querySelector('input[type="file"]') as HTMLInputElement
await user.upload(input, new File(['zip'], 'broken.zip', { type: 'application/zip' }))
await user.click(await screen.findByText('Yes, restore'))
expect(await screen.findByText('not a backup')).toBeInTheDocument()
await waitFor(() => expect(screen.getByTitle('Upload Backup')).toBeEnabled())
})
// BKP-020: Upload button forwards the click to the hidden file input
it('FE-ADMIN-BKP-020: the Upload button opens the hidden file picker', async () => {
const user = userEvent.setup()
render(<BackupPanel />)
await screen.findByText('backup-2025-01-15.zip')
const input = document.querySelector('input[type="file"]') as HTMLInputElement
const clickSpy = vi.spyOn(input, 'click').mockImplementation(() => {})
await user.click(screen.getByTitle('Upload Backup'))
expect(clickSpy).toHaveBeenCalled()
})
// BKP-021: Delete declined / failing
it('FE-ADMIN-BKP-021: declining the confirm keeps the backup, a failing delete toasts', async () => {
const user = userEvent.setup()
let deleteCalls = 0
server.use(
http.delete('/api/backup/:filename', () => {
deleteCalls += 1
return HttpResponse.json({ error: 'file is locked' }, { status: 500 })
}),
)
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('backup-2025-01-15.zip')
const trashBtn = Array.from(document.querySelectorAll('button')).find(
b => b.querySelector('svg.lucide-trash2'),
) as HTMLElement
await user.click(trashBtn)
expect(deleteCalls).toBe(0)
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
confirmSpy.mockReturnValue(true)
await user.click(trashBtn)
expect(await screen.findByText('Failed to delete')).toBeInTheDocument()
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
// BKP-022: Auto settings save fails
it('FE-ADMIN-BKP-022: a failing auto-settings save toasts and keeps the form dirty', async () => {
const user = userEvent.setup()
server.use(http.put('/api/backup/auto-settings', () => HttpResponse.error()))
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('Enable auto-backup')
await user.click(getToggleButton())
await user.click(screen.getByRole('button', { name: /^save$/i }))
expect(await screen.findByText('Failed to save settings')).toBeInTheDocument()
await waitFor(() => expect(screen.getByRole('button', { name: /^save$/i })).toBeEnabled())
})
// BKP-023: Size/date fallbacks
it('FE-ADMIN-BKP-023: missing size and date render as a dash, kilobytes are formatted', async () => {
server.use(
http.get('/api/backup/list', () =>
HttpResponse.json({
backups: [
{ filename: 'empty.zip', created_at: null, size: 0 },
{ filename: 'small.zip', created_at: '2025-03-01T08:00:00Z', size: 5120 },
],
}),
),
)
render(<BackupPanel />)
await screen.findByText('empty.zip')
expect(screen.getAllByText('-')).toHaveLength(2)
expect(screen.getByText('5.0 KB')).toBeInTheDocument()
})
// BKP-024: Invalid server timezone
it('FE-ADMIN-BKP-024: an unusable server timezone falls back to the raw timestamp', async () => {
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'Not/AZone',
}),
),
)
render(<BackupPanel />)
await screen.findByText('backup-2025-01-15.zip')
await waitFor(() => expect(screen.getByText('2025-01-15T10:00:00Z')).toBeInTheDocument())
})
// BKP-025: 12h hour picker
it('FE-ADMIN-BKP-025: the hour picker uses AM/PM labels for 12h users and stores the pick', async () => {
const user = userEvent.setup()
seedStore(useSettingsStore, { settings: { time_format: '12h' } } as any)
let saved: Record<string, unknown> | null = null
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 0, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
}),
),
http.put('/api/backup/auto-settings', async ({ request }) => {
saved = await request.json() as Record<string, unknown>
return HttpResponse.json({ settings: saved })
}),
)
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('Run at hour')
expect(screen.getByText('Server local time (12h format) (Timezone: UTC)')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '12:00 AM' }))
await user.click(await screen.findByRole('button', { name: '2:00 PM' }))
await user.click(screen.getByRole('button', { name: /^save$/i }))
await waitFor(() => expect(saved).toMatchObject({ hour: 14 }))
})
// BKP-026: Monthly interval
it('FE-ADMIN-BKP-026: the monthly interval offers a day-of-month picker', async () => {
const user = userEvent.setup()
let saved: Record<string, unknown> | null = null
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'monthly', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: '',
}),
),
http.put('/api/backup/auto-settings', async ({ request }) => {
saved = await request.json() as Record<string, unknown>
return HttpResponse.json({ settings: saved })
}),
)
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('Day of month')
// No timezone from the server → the hint carries no timezone suffix
expect(screen.getByText('Server local time (24h format)')).toBeInTheDocument()
expect(screen.queryByText('Sun')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '1' }))
await user.click(await screen.findByRole('button', { name: '15' }))
await user.click(screen.getByRole('button', { name: /^save$/i }))
await waitFor(() => expect(saved).toMatchObject({ day_of_month: 15 }))
})
// BKP-027: Day of week + retention
it('FE-ADMIN-BKP-027: day-of-week and retention picks are stored together', async () => {
const user = userEvent.setup()
let saved: Record<string, unknown> | null = null
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'weekly', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
}),
),
http.put('/api/backup/auto-settings', async ({ request }) => {
saved = await request.json() as Record<string, unknown>
return HttpResponse.json({ settings: saved })
}),
)
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('Day of week')
await user.click(screen.getByText('Fri'))
await user.click(screen.getByText('Keep forever'))
await user.click(screen.getByRole('button', { name: /^save$/i }))
await waitFor(() => expect(saved).toMatchObject({ day_of_week: 5, keep_days: 0 }))
})
// BKP-028: Download failure
it('FE-ADMIN-BKP-028: a failing download toasts the download error', async () => {
const user = userEvent.setup()
server.use(http.get('/api/backup/download/:filename', () => HttpResponse.error()))
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('backup-2025-01-15.zip')
await user.click(screen.getByText('Download'))
expect(await screen.findByText('Download failed')).toBeInTheDocument()
})
// BKP-029: Confirm button hover styling
it('FE-ADMIN-BKP-029: the destructive confirm button darkens on hover', async () => {
const user = userEvent.setup()
render(<BackupPanel />)
await screen.findByText('backup-2025-01-15.zip')
await user.click(screen.getAllByText('Restore')[0])
const confirmBtn = await screen.findByText('Yes, restore')
fireEvent.mouseEnter(confirmBtn)
expect(confirmBtn.style.background).toBe('rgb(185, 28, 28)')
fireEvent.mouseLeave(confirmBtn)
expect(confirmBtn.style.background).toBe('rgb(220, 38, 38)')
})
})
+29 -25
View File
@@ -191,7 +191,7 @@ export default function BackupPanel() {
</div>
</div>
<div className="flex items-center gap-2">
<button
<button type="button"
onClick={loadBackups}
disabled={isLoading}
className="p-2 text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
@@ -208,7 +208,7 @@ export default function BackupPanel() {
className="hidden"
onChange={handleUploadRestore}
/>
<button
<button type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60"
@@ -222,7 +222,7 @@ export default function BackupPanel() {
<span className="hidden sm:inline">{isUploading ? t('backup.uploading') : t('backup.upload')}</span>
</button>
<button
<button type="button"
onClick={handleCreate}
disabled={isCreating}
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
@@ -247,7 +247,7 @@ export default function BackupPanel() {
<div className="text-center py-12 text-gray-400">
<HardDrive className="w-10 h-10 mb-3 mx-auto opacity-40" />
<p className="text-sm">{t('backup.empty')}</p>
<button onClick={handleCreate} className="mt-4 text-slate-700 text-sm hover:underline">
<button type="button" onClick={handleCreate} className="mt-4 text-slate-700 text-sm hover:underline">
{t('backup.createFirst')}
</button>
</div>
@@ -274,14 +274,14 @@ export default function BackupPanel() {
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<button
<button type="button"
onClick={() => backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-slate-700 border border-slate-200 rounded-lg hover:bg-slate-50"
>
<Download className="w-3.5 h-3.5" />
{t('backup.download')}
</button>
<button
<button type="button"
onClick={() => handleRestore(backup.filename)}
disabled={restoringFile === backup.filename}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-amber-700 border border-amber-200 rounded-lg hover:bg-amber-50 disabled:opacity-60"
@@ -292,7 +292,7 @@ export default function BackupPanel() {
}
{t('backup.restore')}
</button>
<button
<button type="button"
onClick={() => handleDelete(backup.filename)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
>
@@ -318,11 +318,11 @@ export default function BackupPanel() {
<div className="flex flex-col gap-5">
{/* Enable toggle */}
<label className="flex items-center justify-between gap-4 cursor-pointer">
<div className="min-w-0">
<span className="text-sm font-medium text-gray-900">{t('backup.auto.enable')}</span>
<p className="text-xs text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
<div className="min-w-0 text-sm font-medium text-gray-900">
{t('backup.auto.enable')}
<p className="text-xs font-normal text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
</div>
<button
<button type="button"
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: autoSettings.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
@@ -339,7 +339,7 @@ export default function BackupPanel() {
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.interval')}</label>
<div className="flex flex-wrap gap-2">
{INTERVAL_OPTIONS.map(opt => (
<button
<button type="button"
key={opt.value}
onClick={() => handleAutoSettingsChange('interval', opt.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
@@ -360,7 +360,7 @@ export default function BackupPanel() {
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
<CustomSelect
value={String(autoSettings.hour)}
onChange={v => handleAutoSettingsChange('hour', parseInt(String(v), 10))}
onChange={v => handleAutoSettingsChange('hour', Number.parseInt(String(v), 10))}
size="sm"
options={HOURS.map(h => {
let label: string
@@ -386,7 +386,7 @@ export default function BackupPanel() {
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfWeek')}</label>
<div className="flex flex-wrap gap-2">
{DAYS_OF_WEEK.map(opt => (
<button
<button type="button"
key={opt.value}
onClick={() => handleAutoSettingsChange('day_of_week', opt.value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
@@ -408,7 +408,7 @@ export default function BackupPanel() {
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
<CustomSelect
value={String(autoSettings.day_of_month)}
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(String(v), 10))}
onChange={v => handleAutoSettingsChange('day_of_month', Number.parseInt(String(v), 10))}
size="sm"
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
/>
@@ -421,7 +421,7 @@ export default function BackupPanel() {
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
<div className="flex flex-wrap gap-2">
{KEEP_OPTIONS.map(opt => (
<button
<button type="button"
key={opt.value}
onClick={() => handleAutoSettingsChange('keep_days', opt.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
@@ -440,7 +440,7 @@ export default function BackupPanel() {
{/* Save button */}
<div className="flex justify-end pt-2 border-t border-gray-100">
<button
<button type="button"
onClick={handleSaveAutoSettings}
disabled={autoSettingsSaving || !autoSettingsDirty}
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-5 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50 transition-colors"
@@ -458,11 +458,15 @@ export default function BackupPanel() {
{/* Restore Warning Modal */}
{restoreConfirm && (
<div
// Dismiss-on-backdrop is a mouse shortcut for the Cancel button below;
// the backdrop itself carries no semantics of its own.
role="presentation"
className="bg-[rgba(0,0,0,0.5)]"
style={{ position: 'fixed', inset: 0, zIndex: 9999, backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={() => setRestoreConfirm(null)}
>
<div
role="presentation"
onClick={e => e.stopPropagation()}
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
@@ -473,10 +477,10 @@ export default function BackupPanel() {
<AlertTriangle size={20} className="text-white" />
</div>
<div>
<h3 className="text-white" style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>
<h3 className="text-white" style={{ margin: 0, fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700 }}>
{t('backup.restoreConfirmTitle')}
</h3>
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 12 }}>
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
{restoreConfirm.filename}
</p>
</div>
@@ -484,11 +488,11 @@ export default function BackupPanel() {
{/* Body */}
<div style={{ padding: '20px 24px' }}>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, margin: 0 }}>
{t('backup.restoreWarning')}
</p>
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.5 }}
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
>
{t('backup.restoreTip')}
@@ -497,17 +501,17 @@ export default function BackupPanel() {
{/* Footer */}
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button
<button type="button"
onClick={() => setRestoreConfirm(null)}
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.cancel')}
</button>
<button
<button type="button"
onClick={executeRestore}
className="bg-[#dc2626] text-white"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
>
@@ -1,5 +1,5 @@
// FE-COMP-CAT-001 to FE-COMP-CAT-012
import { render, screen, waitFor } from '../../../tests/helpers/render';
// FE-COMP-CAT-001 to FE-COMP-CAT-020
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -27,17 +27,17 @@ describe('CategoryManager', () => {
it('FE-COMP-CAT-002: shows Categories title', async () => {
render(<CategoryManager />);
await screen.findByText('Categories');
expect(await screen.findByText('Categories')).toBeInTheDocument();
});
it('FE-COMP-CAT-003: shows empty state when no categories', async () => {
render(<CategoryManager />);
await screen.findByText('No categories yet');
expect(await screen.findByText('No categories yet')).toBeInTheDocument();
});
it('FE-COMP-CAT-004: shows New Category button', async () => {
render(<CategoryManager />);
await screen.findByText('New Category');
expect(await screen.findByText('New Category')).toBeInTheDocument();
});
it('FE-COMP-CAT-005: clicking New Category shows form', async () => {
@@ -129,7 +129,7 @@ describe('CategoryManager', () => {
it('FE-COMP-CAT-010: shows subtitle text', async () => {
render(<CategoryManager />);
await screen.findByText('Manage categories for places');
expect(await screen.findByText('Manage categories for places')).toBeInTheDocument();
});
it('FE-COMP-CAT-011: category count is shown', async () => {
@@ -156,4 +156,148 @@ describe('CategoryManager', () => {
await user.click(screen.getByText('Cancel'));
expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument();
});
it('FE-COMP-CAT-013: a failing list request toasts and falls back to the empty state', async () => {
server.use(http.get('/api/categories', () => HttpResponse.error()));
render(<><ToastContainer /><CategoryManager /></>);
expect(await screen.findByText('Failed to load categories')).toBeInTheDocument();
expect(screen.getByText('No categories yet')).toBeInTheDocument();
});
it('FE-COMP-CAT-014: editing a category sends a PUT and replaces the row', async () => {
const user = userEvent.setup();
let body: Record<string, unknown> | null = null;
server.use(
http.get('/api/categories', () =>
HttpResponse.json({ categories: [buildCategory({ id: 5, name: 'Hotels', color: '#6366f1', icon: 'MapPin' })] })
),
http.put('/api/categories/5', async ({ request }) => {
body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ category: buildCategory({ id: 5, name: 'Lodging', color: '#ef4444', icon: 'BedDouble' }) });
}),
);
render(<><ToastContainer /><CategoryManager /></>);
await screen.findByText('Hotels');
await user.click(screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category'))[0]);
const nameInput = screen.getByDisplayValue('Hotels');
await user.clear(nameInput);
await user.type(nameInput, 'Lodging');
await user.click(screen.getByTitle('Hotel'));
await user.click(screen.getByText('Update'));
expect(await screen.findByText('Category updated')).toBeInTheDocument();
expect(body).toEqual({ name: 'Lodging', color: '#6366f1', icon: 'BedDouble' });
expect(screen.getByText('Lodging')).toBeInTheDocument();
});
it('FE-COMP-CAT-015: a failing save surfaces the server message', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/categories', () => HttpResponse.json({ error: 'name already taken' }, { status: 409 })),
);
render(<><ToastContainer /><CategoryManager /></>);
await screen.findByText('New Category');
await user.click(screen.getByText('New Category'));
await user.type(screen.getByPlaceholderText('Category name'), 'Parks');
await user.click(screen.getByText('Create'));
expect(await screen.findByText('name already taken')).toBeInTheDocument();
// The form stays open so the name can be corrected
expect(screen.getByDisplayValue('Parks')).toBeInTheDocument();
});
it('FE-COMP-CAT-016: declining the delete confirm keeps the category', async () => {
const user = userEvent.setup();
let deleteCalled = false;
server.use(
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
http.delete('/api/categories/9', () => { deleteCalled = true; return HttpResponse.json({ success: true }); }),
);
vi.spyOn(window, 'confirm').mockReturnValue(false);
render(<CategoryManager />);
await screen.findByText('Parks');
const actionBtns = screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category'));
await user.click(actionBtns[1]);
expect(deleteCalled).toBe(false);
expect(screen.getByText('Parks')).toBeInTheDocument();
vi.restoreAllMocks();
});
it('FE-COMP-CAT-017: a failing delete toasts and keeps the row', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
http.delete('/api/categories/9', () => HttpResponse.json({ error: 'category in use' }, { status: 409 })),
);
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<><ToastContainer /><CategoryManager /></>);
await screen.findByText('Parks');
const actionBtns = screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category'));
await user.click(actionBtns[1]);
expect(await screen.findByText('category in use')).toBeInTheDocument();
expect(screen.getByText('Parks')).toBeInTheDocument();
vi.restoreAllMocks();
});
it('FE-COMP-CAT-018: picking an icon and a preset colour updates the live preview', async () => {
const user = userEvent.setup();
render(<CategoryManager />);
await screen.findByText('New Category');
await user.click(screen.getByText('New Category'));
// Empty name → the preview falls back to the generic label
expect(screen.getByText('Category')).toBeInTheDocument();
await user.type(screen.getByPlaceholderText('Category name'), 'Beach day');
await user.click(screen.getByTitle('Beach'));
const preview = screen.getByText('Beach day');
expect(preview).toHaveStyle({ color: '#6366f1' });
await user.click(document.querySelectorAll('button[style*="background-color: rgb(239, 68, 68)"]')[0]);
expect(screen.getByText('Beach day')).toHaveStyle({ color: '#ef4444' });
});
it('FE-COMP-CAT-019: the custom colour swatch opens the native picker and adopts its value', async () => {
const user = userEvent.setup();
render(<CategoryManager />);
await screen.findByText('New Category');
await user.click(screen.getByText('New Category'));
const colorInput = document.querySelector('input[type="color"]') as HTMLInputElement;
const clickSpy = vi.spyOn(colorInput, 'click').mockImplementation(() => {});
await user.click(screen.getByTitle('Choose custom color'));
expect(clickSpy).toHaveBeenCalled();
fireEvent.change(colorInput, { target: { value: '#123456' } });
await waitFor(() => expect(screen.getByText('Category')).toHaveStyle({ color: '#123456' }));
// A non-preset colour fills the custom swatch instead of showing the pipette
expect(screen.getByTitle('Choose custom color')).toHaveStyle({ backgroundColor: '#123456' });
vi.restoreAllMocks();
});
it('FE-COMP-CAT-020: starting an edit closes the create form', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 3, name: 'Hotels' })] })),
);
render(<CategoryManager />);
await screen.findByText('Hotels');
await user.click(screen.getByText('New Category'));
expect(screen.getByPlaceholderText('Category name')).toHaveValue('');
const row = screen.getByText('Hotels').closest('.p-3') as HTMLElement;
await user.click(within(row).getAllByRole('button')[0]);
// Only the inline edit form remains, pre-filled with the row's name
expect(screen.getAllByPlaceholderText('Category name')).toHaveLength(1);
expect(screen.getByDisplayValue('Hotels')).toBeInTheDocument();
});
});
@@ -56,8 +56,8 @@ export default function CategoryManager() {
setEditingId(null)
}
// The Save button carries disabled={… || !form.name.trim()}, so the name is set here.
const handleSave = async () => {
if (!form.name.trim()) { toast.error(t('categories.toast.nameRequired')); return }
setIsSaving(true)
try {
if (editingId) {
@@ -194,7 +194,7 @@ export default function CategoryManager() {
<h2 className="font-semibold text-content">{t('categories.title')}</h2>
<p className="text-xs mt-1 text-content-muted">{t('categories.subtitle')}</p>
</div>
<button onClick={handleStartCreate}
<button type="button" onClick={handleStartCreate}
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
<Plus className="w-4 h-4" />
<span className="hidden sm:inline">{t('categories.new')}</span>
@@ -235,11 +235,11 @@ export default function CategoryManager() {
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => handleStartEdit(cat)}
<button type="button" onClick={() => handleStartEdit(cat)}
className="p-1.5 text-gray-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(cat.id)}
<button type="button" onClick={() => handleDelete(cat.id)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg">
<Trash2 className="w-4 h-4" />
</button>
@@ -0,0 +1,455 @@
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-027
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildAdmin } from '../../../tests/helpers/factories';
import { useAuthStore } from '../../store/authStore';
import { ToastContainer } from '../shared/Toast';
import DefaultUserSettingsTab from './DefaultUserSettingsTab';
// The tile preview would pull Leaflet into jsdom; the panel only needs it to render.
vi.mock('../Map/MapView', () => ({
MapView: ({ tileUrl }: { tileUrl?: string }) => <div data-testid="map-preview" data-tile={tileUrl} />,
}));
const MAPBOX_STANDARD = 'mapbox://styles/mapbox/standard';
const MAPBOX_DARK = 'mapbox://styles/mapbox/dark-v11';
const MAPBOX_NAV_NIGHT = 'mapbox://styles/mapbox/navigation-night-v1';
const OFM_LIBERTY = 'https://tiles.openfreemap.org/styles/liberty';
const TILE_PLACEHOLDER = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
/** Stateful stand-in for the admin defaults endpoint: PUT merges, null deletes. */
function stubDefaults(initial: Record<string, unknown> = {}) {
const state: Record<string, unknown> = { ...initial };
const puts: Record<string, unknown>[] = [];
server.use(
http.get('/api/admin/default-user-settings', () => HttpResponse.json(state)),
http.put('/api/admin/default-user-settings', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
puts.push(body);
for (const [key, value] of Object.entries(body)) {
if (value === null) delete state[key];
else state[key] = value;
}
return HttpResponse.json({ ...state });
}),
);
return { puts, state };
}
function withToast() {
return render(<><ToastContainer /><DefaultUserSettingsTab /></>);
}
/** The selected option button is the one drawn with the strong border token. */
function isActive(button: HTMLElement): boolean {
return (button.style.border || '').includes('var(--text-primary)');
}
/**
* The reset link sits inside the field's own <label>; because a button is a labelable
* element the wrapping label becomes its accessible name, so it is queried positionally.
*/
function resetLink(label: string): HTMLElement {
const el = screen.getAllByText(label).find(node => node.tagName === 'LABEL');
if (!el) throw new Error(`no label found for ${label}`);
return within(el).getByRole('button');
}
function hasResetLink(label: string): boolean {
const el = screen.getAllByText(label).find(node => node.tagName === 'LABEL');
return !!el && within(el).queryByRole('button') !== null;
}
/** The CARTO key input sits under its label inside the field's own wrapper. */
function cartoInput(): HTMLInputElement {
return within(screen.getByText('Shared CARTO key').closest('div') as HTMLElement).getByRole('textbox');
}
/** Opens a CustomSelect by its trigger label and picks an option from the portal. */
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string) {
await user.click(screen.getByRole('button', { name: trigger }));
const choices = await screen.findAllByRole('button', { name: option });
await user.click(choices[choices.length - 1]);
}
describe('DefaultUserSettingsTab', () => {
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
stubDefaults();
});
it('FE-ADMIN-DUS-001: shows the loading placeholder until the defaults arrive', async () => {
render(<DefaultUserSettingsTab />);
expect(screen.getByText('Loading…')).toBeInTheDocument();
expect(await screen.findByText('Default User Settings')).toBeInTheDocument();
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
});
it('FE-ADMIN-DUS-002: renders every field with no reset links while nothing is set', async () => {
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
for (const name of ['Light', 'Dark', 'Auto', '°C Celsius', 'km Metric', '24h (14:30)', 'On', 'Off']) {
expect(isActive(screen.getByRole('button', { name }))).toBe(false);
}
for (const label of ['Color Mode', 'Temperature Unit', 'Distance Unit', 'Time Format', 'Display currency', 'Map Template']) {
expect(hasResetLink(label)).toBe(false);
}
expect(screen.getByTestId('map-preview')).toBeInTheDocument();
});
it('FE-ADMIN-DUS-003: a failing load still renders the panel with built-in defaults', async () => {
server.use(http.get('/api/admin/default-user-settings', () => HttpResponse.json({}, { status: 500 })));
render(<DefaultUserSettingsTab />);
expect(await screen.findByText('Default User Settings')).toBeInTheDocument();
expect(hasResetLink('Map engine')).toBe(false);
expect(isActive(screen.getByRole('button', { name: 'Standard (free)' }))).toBe(true);
});
it('FE-ADMIN-DUS-004: picking a colour mode saves it and confirms with a toast', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults();
withToast();
await screen.findByText('Default User Settings');
await user.click(screen.getByRole('button', { name: 'Dark' }));
expect(await screen.findByText('Default saved')).toBeInTheDocument();
expect(puts).toEqual([{ dark_mode: 'dark' }]);
await waitFor(() => expect(isActive(screen.getByRole('button', { name: 'Dark' }))).toBe(true));
expect(resetLink('Color Mode')).toBeInTheDocument();
});
it('FE-ADMIN-DUS-005: a legacy boolean dark_mode still highlights the matching option', async () => {
stubDefaults({ dark_mode: true });
const { unmount } = render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
expect(isActive(screen.getByRole('button', { name: 'Dark' }))).toBe(true);
expect(isActive(screen.getByRole('button', { name: 'Light' }))).toBe(false);
unmount();
stubDefaults({ dark_mode: false });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
expect(isActive(screen.getByRole('button', { name: 'Light' }))).toBe(true);
expect(isActive(screen.getByRole('button', { name: 'Auto' }))).toBe(false);
});
it('FE-ADMIN-DUS-006: unit and time-format options each save their own key', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults();
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await user.click(screen.getByRole('button', { name: '°F Fahrenheit' }));
await waitFor(() => expect(resetLink('Temperature Unit')).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: 'mi Imperial' }));
await waitFor(() => expect(resetLink('Distance Unit')).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: '12h (2:30 PM)' }));
await waitFor(() => expect(puts).toEqual([
{ temperature_unit: 'fahrenheit' },
{ distance_unit: 'imperial' },
{ time_format: '12h' },
]));
});
it('FE-ADMIN-DUS-007: a set default gets a reset link that clears it server-side', async () => {
const user = userEvent.setup();
const { puts, state } = stubDefaults({ temperature_unit: 'celsius' });
withToast();
await screen.findByText('Default User Settings');
expect(isActive(screen.getByRole('button', { name: '°C Celsius' }))).toBe(true);
await user.click(resetLink('Temperature Unit'));
expect(await screen.findByText('Reset to built-in default')).toBeInTheDocument();
expect(puts).toEqual([{ temperature_unit: null }]);
expect(state.temperature_unit).toBeUndefined();
await waitFor(() => expect(hasResetLink('Temperature Unit')).toBe(false));
});
it('FE-ADMIN-DUS-008: the currency picker saves the chosen code and can be reset', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ default_currency: 'USD' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await pickFromSelect(user, 'USD $', 'EUR €');
await waitFor(() => expect(puts).toEqual([{ default_currency: 'EUR' }]));
await waitFor(() => expect(screen.getByRole('button', { name: 'EUR €' })).toBeInTheDocument());
await user.click(resetLink('Display currency'));
await waitFor(() => expect(puts).toHaveLength(2));
expect(puts[1]).toEqual({ default_currency: null });
});
it('FE-ADMIN-DUS-009: the blur-booking-codes options save booleans', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults();
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await user.click(screen.getByRole('button', { name: 'On' }));
await waitFor(() => expect(isActive(screen.getByRole('button', { name: 'On' }))).toBe(true));
await user.click(screen.getByRole('button', { name: 'Off' }));
await waitFor(() => expect(puts).toEqual([
{ blur_booking_codes: true },
{ blur_booking_codes: false },
]));
});
it('FE-ADMIN-DUS-010: the tile preset dropdown fills the URL field and hands it to the preview', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults();
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await pickFromSelect(user, 'Select template...', 'CartoDB Dark');
const url = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
await waitFor(() => expect(puts).toEqual([{ map_tile_url: url }]));
expect(screen.getByPlaceholderText(TILE_PLACEHOLDER)).toHaveValue(url);
expect(screen.getByTestId('map-preview')).toHaveAttribute('data-tile', url);
});
it('FE-ADMIN-DUS-011: a hand-typed tile URL is saved on blur', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults();
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
const input = screen.getByPlaceholderText(TILE_PLACEHOLDER);
// userEvent reads {...} as key descriptors, so the placeholders are omitted here
await user.type(input, 'https://tiles.example.org/tile.png');
fireEvent.blur(input);
await waitFor(() => expect(puts).toEqual([{ map_tile_url: 'https://tiles.example.org/tile.png' }]));
expect(screen.getByTestId('map-preview')).toHaveAttribute('data-tile', 'https://tiles.example.org/tile.png');
});
it('FE-ADMIN-DUS-012: resetting the tile URL clears the input too', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_tile_url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
expect(screen.getByRole('button', { name: 'OpenStreetMap DE' })).toBeInTheDocument();
await user.click(resetLink('Map Template'));
await waitFor(() => expect(puts).toEqual([{ map_tile_url: null }]));
await waitFor(() => expect(screen.getByPlaceholderText(TILE_PLACEHOLDER)).toHaveValue(''));
});
it('FE-ADMIN-DUS-013: leaflet hides the GL-only token and style fields', async () => {
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
expect(isActive(screen.getByRole('button', { name: 'Standard (free)' }))).toBe(true);
expect(screen.queryByText('Map style')).not.toBeInTheDocument();
expect(screen.queryByText('Shared Mapbox token')).not.toBeInTheDocument();
});
it('FE-ADMIN-DUS-014: switching to Mapbox stores the provider with its own style slot', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults();
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await user.click(screen.getByRole('button', { name: 'Mapbox (3D)' }));
await waitFor(() => expect(puts).toEqual([{ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD }]));
expect(await screen.findByText('Shared Mapbox token')).toBeInTheDocument();
expect(screen.getByDisplayValue(MAPBOX_STANDARD)).toBeInTheDocument();
});
it('FE-ADMIN-DUS-015: switching to MapLibre stores the OpenFreeMap default and hides the token field', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults();
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await user.click(screen.getByRole('button', { name: 'MapLibre (OpenFreeMap)' }));
await waitFor(() => expect(puts).toEqual([{ map_provider: 'maplibre-gl', maplibre_style: OFM_LIBERTY }]));
expect(await screen.findByText('Map style')).toBeInTheDocument();
expect(screen.queryByText('Shared Mapbox token')).not.toBeInTheDocument();
expect(screen.getByDisplayValue(OFM_LIBERTY)).toBeInTheDocument();
});
it('FE-ADMIN-DUS-016: switching back to the standard engine only stores the provider', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD });
render(<DefaultUserSettingsTab />);
await screen.findByText('Map style');
await user.click(screen.getByRole('button', { name: 'Standard (free)' }));
await waitFor(() => expect(puts).toEqual([{ map_provider: 'leaflet' }]));
await waitFor(() => expect(screen.queryByText('Map style')).not.toBeInTheDocument());
});
it('FE-ADMIN-DUS-017: a Mapbox default holding an OpenFreeMap style falls back to the Mapbox standard', async () => {
stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: OFM_LIBERTY });
render(<DefaultUserSettingsTab />);
await screen.findByText('Map style');
expect(screen.getByDisplayValue(MAPBOX_STANDARD)).toBeInTheDocument();
});
it('FE-ADMIN-DUS-018: a stored Mapbox style survives while the standard engine is active', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_provider: 'leaflet', mapbox_style: MAPBOX_DARK });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
expect(screen.queryByText('Map style')).not.toBeInTheDocument();
// Switching to Mapbox re-uses the stored slot instead of resetting it
await user.click(screen.getByRole('button', { name: 'Mapbox (3D)' }));
await waitFor(() => expect(puts).toEqual([{ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_DARK }]));
expect(screen.getByDisplayValue(MAPBOX_DARK)).toBeInTheDocument();
});
it('FE-ADMIN-DUS-019: the shared Mapbox token is stored on blur and cleared by its reset link', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_access_token: 'pk.old' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Shared Mapbox token');
const input = screen.getByPlaceholderText('pk.eyJ…');
expect(input).toHaveValue('pk.old');
await user.clear(input);
await user.type(input, 'pk.new');
fireEvent.blur(input);
await waitFor(() => expect(puts).toEqual([{ mapbox_access_token: 'pk.new' }]));
// Clicking the reset link also blurs the field again, so only the last PUT is checked
await user.click(resetLink('Shared Mapbox token'));
await waitFor(() => expect(puts[puts.length - 1]).toEqual({ mapbox_access_token: null }));
await waitFor(() => expect(screen.getByPlaceholderText('pk.eyJ…')).toHaveValue(''));
});
it('FE-ADMIN-DUS-020: a hand-typed MapLibre style is normalised to OpenFreeMap on blur', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_provider: 'maplibre-gl', maplibre_style: OFM_LIBERTY });
render(<DefaultUserSettingsTab />);
await screen.findByText('Map style');
const input = screen.getByDisplayValue(OFM_LIBERTY);
await user.clear(input);
await user.type(input, 'https://example.com/custom.json');
fireEvent.blur(input);
await waitFor(() => expect(puts).toEqual([{ maplibre_style: OFM_LIBERTY }]));
expect(input).toHaveValue(OFM_LIBERTY);
});
it('FE-ADMIN-DUS-021: the style dropdown writes the picked preset into the active provider slot', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD });
render(<DefaultUserSettingsTab />);
await screen.findByText('Map style');
await pickFromSelect(user, 'Mapbox Standard', 'Navigation Night');
await waitFor(() => expect(puts).toEqual([{ mapbox_style: MAPBOX_NAV_NIGHT }]));
expect(screen.getByDisplayValue(MAPBOX_NAV_NIGHT)).toBeInTheDocument();
});
it('FE-ADMIN-DUS-022: resetting the style restores the provider default in the field', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_DARK });
render(<DefaultUserSettingsTab />);
await screen.findByText('Map style');
await user.click(resetLink('Map style'));
await waitFor(() => expect(puts).toEqual([{ mapbox_style: null }]));
await waitFor(() => expect(screen.getByDisplayValue(MAPBOX_STANDARD)).toBeInTheDocument());
});
it('FE-ADMIN-DUS-023: the Mapbox 3D and quality options start on their built-in defaults and save their own keys', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD });
render(<DefaultUserSettingsTab />);
await screen.findByText('3D buildings & terrain');
// 3D defaults to on, quality mode to off when neither is stored
const threeD = within(screen.getByText('3D buildings & terrain').closest('div') as HTMLElement);
expect(isActive(threeD.getByRole('button', { name: 'On' }))).toBe(true);
const quality = within(screen.getByText('High-quality mode').closest('div') as HTMLElement);
expect(isActive(quality.getByRole('button', { name: 'Off' }))).toBe(true);
await user.click(threeD.getByRole('button', { name: 'Off' }));
await waitFor(() => expect(puts).toEqual([{ mapbox_3d_enabled: false }]));
await user.click(quality.getByRole('button', { name: 'On' }));
await waitFor(() => expect(puts).toHaveLength(2));
expect(puts[1]).toEqual({ mapbox_quality_mode: true });
});
it('FE-ADMIN-DUS-024: a rejected save surfaces the request error instead of a success toast', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/default-user-settings', () => HttpResponse.json({})),
http.put('/api/admin/default-user-settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
);
withToast();
await screen.findByText('Default User Settings');
await user.click(screen.getByRole('button', { name: 'Dark' }));
expect(await screen.findByText(/Request failed with status code 500/)).toBeInTheDocument();
expect(screen.queryByText('Default saved')).not.toBeInTheDocument();
});
it('FE-ADMIN-DUS-025: a rejected reset surfaces the request error', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/default-user-settings', () => HttpResponse.json({ time_format: '12h' })),
http.put('/api/admin/default-user-settings', () => HttpResponse.json({ error: 'nope' }, { status: 503 })),
);
withToast();
await screen.findByText('Default User Settings');
await user.click(resetLink('Time Format'));
expect(await screen.findByText(/Request failed with status code 503/)).toBeInTheDocument();
expect(screen.queryByText('Reset to built-in default')).not.toBeInTheDocument();
});
it('FE-ADMIN-DUS-026: the shared CARTO key is stored on blur and cleared by its reset link', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ carto_api_key: 'ck.old' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Shared CARTO key');
const input = cartoInput();
expect(input).toHaveValue('ck.old');
await user.clear(input);
await user.type(input, 'ck.new');
fireEvent.blur(input);
await waitFor(() => expect(puts).toEqual([{ carto_api_key: 'ck.new' }]));
// Clicking the reset link also blurs the field again, so only the last PUT is checked
await user.click(resetLink('Shared CARTO key'));
await waitFor(() => expect(puts[puts.length - 1]).toEqual({ carto_api_key: null }));
await waitFor(() => expect(cartoInput()).toHaveValue(''));
});
it('FE-ADMIN-DUS-027: a managed instance hides the shared CARTO key field', async () => {
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), managed: true });
stubDefaults({ carto_api_key: 'ck.hoster' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
expect(screen.queryByText('Shared CARTO key')).not.toBeInTheDocument();
});
});
@@ -6,8 +6,9 @@ import { useToast } from '../shared/Toast'
import Section from '../Settings/Section'
import CustomSelect from '../shared/CustomSelect'
import { MapView } from '../Map/MapView'
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
import type { DistanceUnit, Place } from '../../types'
import { normalizeTileUrl } from '../../utils/tileUrl'
import {
MAPBOX_DEFAULT_STYLE,
defaultStyleForProvider,
@@ -17,10 +18,17 @@ import {
styleSettingKey,
type GlMapProvider,
} from '../Map/glProviders'
import { useAuthStore } from '../../store/authStore'
const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
// The app default, and a vector style rather than a {z}/{x}/{y} template: no
// key, no registration, no request limits.
{ name: 'OpenFreeMap Positron', url: 'https://tiles.openfreemap.org/styles/positron' },
{ name: 'OpenFreeMap Bright', url: 'https://tiles.openfreemap.org/styles/bright' },
// CARTO watermarks keyless tiles since 26.08.2026 and issues keys by mail, so
// these two need one; without it the map falls back to the default (#2054).
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
@@ -34,6 +42,7 @@ type Defaults = {
default_currency?: string
blur_booking_codes?: boolean
map_tile_url?: string
carto_api_key?: string
map_provider?: string
mapbox_access_token?: string
mapbox_style?: string
@@ -48,8 +57,8 @@ function normalizeProvider(value: unknown): MapProvider {
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
}
function styleForProvider(provider: MapProvider, style?: string | null): string {
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
/** Only the GL providers keep a style — Leaflet is handled by its callers. */
function styleForProvider(provider: GlMapProvider, style?: string | null): string {
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
return normalizeStyleForProvider(provider, style)
}
@@ -84,12 +93,12 @@ function OptionButton({
children: React.ReactNode
}) {
return (
<button
<button type="button"
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
@@ -107,15 +116,18 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const [defaults, setDefaults] = useState<Defaults>({})
const [loaded, setLoaded] = useState(false)
const [mapTileUrl, setMapTileUrl] = useState('')
const managed = useAuthStore((s) => s.managed)
const [mapboxToken, setMapboxToken] = useState('')
const [cartoKey, setCartoKey] = useState('')
const [mapboxStyle, setMapboxStyle] = useState('')
useEffect(() => {
adminApi.getDefaultUserSettings().then((data: Defaults) => {
const provider = normalizeProvider(data.map_provider)
setDefaults(data)
setMapTileUrl(data.map_tile_url || '')
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
setMapboxToken(data.mapbox_access_token || '')
setCartoKey(data.carto_api_key || '')
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
setLoaded(true)
}).catch(() => setLoaded(true))
@@ -137,6 +149,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
setDefaults(updated)
if (key === 'map_tile_url') setMapTileUrl('')
if (key === 'mapbox_access_token') setMapboxToken('')
if (key === 'carto_api_key') setCartoKey('')
if (key === 'mapbox_style' || key === 'maplibre_style') {
const provider = normalizeProvider(defaults.map_provider)
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
@@ -151,7 +164,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const ResetButton = ({ field }: { field: keyof Defaults }) =>
isSet(field) ? (
<button
<button type="button"
onClick={() => reset(field)}
className="text-xs ml-2 text-content-faint underline"
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
@@ -182,11 +195,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
transport_mode: null,
website: null,
phone: null,
created_at: Date(),
created_at: String(new Date()),
}], [])
if (!loaded) {
return <p className="text-content-faint" style={{ fontSize: 12, fontStyle: 'italic', padding: 16 }}>Loading…</p>
return <p className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontStyle: 'italic', padding: 16 }}>Loading…</p>
}
const darkMode = defaults.dark_mode
@@ -286,7 +299,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
onChange={(value: string) => { if (value) save({ default_currency: value }) }}
placeholder={t('settings.currency')}
searchable
options={CURRENCIES.map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
options={currenciesWith(defaults.default_currency).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
size="sm"
style={{ maxWidth: 240 }}
/>
@@ -328,10 +341,30 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
value={mapTileUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
onBlur={() => save({ map_tile_url: mapTileUrl })}
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs mt-1 text-content-faint">{t('settings.mapDefaultHint')}</p>
{/* The key comes with the instance on a managed install, injected when the
settings are read. A field here would only let somebody save a worse one. */}
{!managed && (
<div style={{ marginTop: 14 }}>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.cartoKey')}
<ResetButton field="carto_api_key" />
</label>
<input
type="text"
value={cartoKey}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCartoKey(e.target.value)}
onBlur={() => save({ carto_api_key: cartoKey })}
spellCheck={false}
autoComplete="off"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
</div>
)}
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{React.createElement(MapView as any, {
@@ -378,7 +411,9 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{mapProvider !== 'leaflet' && (
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
{mapProvider === 'mapbox-gl' && (
{/* The token comes with the instance on a managed install, injected when the
settings are read. A field here would only let somebody save a worse one. */}
{mapProvider === 'mapbox-gl' && !managed && (
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.mapboxToken')}
@@ -1,5 +1,5 @@
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-010
import { render, screen, waitFor } from '../../../tests/helpers/render';
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-016
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -87,7 +87,7 @@ describe('DevNotificationsPanel', () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText('Sent: simple-me');
expect(await screen.findByText('Sent: simple-me')).toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-007: all buttons disabled while a send is in-flight', async () => {
@@ -101,8 +101,8 @@ describe('DevNotificationsPanel', () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
// Fire the click but do not await — handler never resolves so sending stays true
void user.click(screen.getByText('Simple → Me').closest('button')!);
// The request handler never resolves, so sending stays true after the click settles
await user.click(screen.getByText('Simple → Me').closest('button')!);
await waitFor(() => {
const buttons = screen.getAllByRole('button');
@@ -110,7 +110,20 @@ describe('DevNotificationsPanel', () => {
});
});
it('FE-ADMIN-DEVNOTIF-008: error toast shown on API failure', async () => {
it('FE-ADMIN-DEVNOTIF-008: the server error field is what the toast shows', async () => {
server.use(
http.post('/api/admin/dev/test-notification', () =>
HttpResponse.json({ error: 'No channel configured' }, { status: 500 }),
),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
expect(await screen.findByText('No channel configured')).toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-008b: a failure without an error field falls back to the generic text', async () => {
server.use(
http.post('/api/admin/dev/test-notification', () =>
HttpResponse.json({ message: 'Server error' }, { status: 500 }),
@@ -120,7 +133,7 @@ describe('DevNotificationsPanel', () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText(/failed|error/i);
expect(await screen.findByText('Failed')).toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-009: changing trip selector updates payload targetId', async () => {
@@ -157,4 +170,141 @@ describe('DevNotificationsPanel', () => {
await screen.findByText('User-Scoped Events');
expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-011: the remaining self/admin type buttons each fire their own event', async () => {
const bodies: Record<string, unknown>[] = [];
server.use(
http.post('/api/admin/dev/test-notification', async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>);
return HttpResponse.json({ ok: true });
}),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Boolean → Me').closest('button')!);
await screen.findByText('Sent: boolean-me');
await user.click(screen.getByText('Navigate → Me').closest('button')!);
await screen.findByText('Sent: navigate-me');
await user.click(screen.getByText('Simple → All Admins').closest('button')!);
await screen.findByText('Sent: simple-admins');
await user.click(screen.getByText('version_available').closest('button')!);
await screen.findByText('Sent: version_available');
expect(bodies[0]).toMatchObject({
event: 'test_boolean',
scope: 'user',
targetId: ADMIN_USER.id,
inApp: {
type: 'boolean',
positiveCallback: { action: 'test_approve', payload: {} },
negativeCallback: { action: 'test_deny', payload: {} },
},
});
expect(bodies[1]).toMatchObject({ event: 'test_navigate', scope: 'user', targetId: ADMIN_USER.id });
expect(bodies[2]).toMatchObject({ event: 'test_simple', scope: 'admin', targetId: 0 });
expect(bodies[3]).toMatchObject({ event: 'version_available', scope: 'admin', targetId: 0, params: { version: '9.9.9-test' } });
});
it('FE-ADMIN-DEVNOTIF-012: every trip-scoped button carries the selected trip and the actor', async () => {
const bodies: Record<string, unknown>[] = [];
server.use(
http.post('/api/admin/dev/test-notification', async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>);
return HttpResponse.json({ ok: true });
}),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Trip-Scoped Events');
const [tripSelect] = screen.getAllByRole('combobox');
const tripId = Number((tripSelect as HTMLSelectElement).value);
for (const label of ['trip_reminder', 'photos_shared', 'collab_message', 'packing_tagged']) {
await user.click(screen.getByText(label).closest('button')!);
await screen.findByText(`Sent: ${label}`);
}
expect(bodies.map(b => b.event)).toEqual(['trip_reminder', 'photos_shared', 'collab_message', 'packing_tagged']);
for (const body of bodies) {
expect(body.scope).toBe('trip');
expect(body.targetId).toBe(tripId);
expect(body.params).toMatchObject({ trip: 'Paris Adventure', tripId: String(tripId) });
}
expect(bodies[1].params).toMatchObject({ actor: 'testadmin', count: '5' });
expect(bodies[2].params).toMatchObject({ preview: 'This is a test message preview.' });
expect(bodies[3].params).toMatchObject({ category: 'Clothing' });
});
it('FE-ADMIN-DEVNOTIF-013: user-scoped events target the picked recipient', async () => {
const bodies: Record<string, unknown>[] = [];
server.use(
http.post('/api/admin/dev/test-notification', async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>);
return HttpResponse.json({ ok: true });
}),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('User-Scoped Events');
const userSelect = screen.getAllByRole('combobox')[1] as HTMLSelectElement;
const aliceOption = Array.from(userSelect.querySelectorAll('option')).find(
o => (o.textContent ?? '').includes('alice'),
)!;
await user.selectOptions(userSelect, aliceOption.value);
const aliceId = Number(aliceOption.value);
await user.click(screen.getByText('trip_invite').closest('button')!);
await screen.findByText(`Sent: trip_invite-${aliceId}`);
await user.click(screen.getByText('vacay_invite').closest('button')!);
await screen.findByText(`Sent: vacay_invite-${aliceId}`);
expect(bodies[0]).toMatchObject({
event: 'trip_invite',
scope: 'user',
targetId: aliceId,
params: { actor: 'testadmin', invitee: 'alice@example.com' },
});
expect(bodies[1]).toMatchObject({
event: 'vacay_invite',
scope: 'user',
targetId: aliceId,
params: { actor: 'testadmin', planId: '1' },
});
});
it('FE-ADMIN-DEVNOTIF-014: the User-Scoped section is hidden when no users come back', async () => {
server.use(http.get('/api/admin/users', () => HttpResponse.json({ users: [] })));
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Trip-Scoped Events');
expect(screen.queryByText('User-Scoped Events')).not.toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-015: failing lookups leave both scoped sections out without crashing', async () => {
server.use(
http.get('/api/trips', () => HttpResponse.error()),
http.get('/api/admin/users', () => HttpResponse.error()),
);
render(<><ToastContainer /><DevNotificationsPanel /></>);
expect(await screen.findByText('Type Testing')).toBeInTheDocument();
await waitFor(() => expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument());
expect(screen.queryByText('User-Scoped Events')).not.toBeInTheDocument();
expect(screen.getByText('Admin-Scoped Events')).toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-016: hovering a trigger paints and restores its background', async () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
const btn = screen.getByText('Simple → Me').closest('button')!;
fireEvent.mouseEnter(btn);
expect(btn.style.background).toBe('var(--bg-hover)');
fireEvent.mouseLeave(btn);
expect(btn.style.background).toBe('var(--bg-card)');
});
});
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'
import { adminApi, tripsApi } from '../../api/client'
import { getApiErrorMessage } from '../../utils/apiError'
import { useAuthStore } from '../../store/authStore'
import { useToast } from '../shared/Toast'
import {
@@ -46,8 +47,8 @@ export default function DevNotificationsPanel(): React.ReactElement {
try {
await adminApi.sendTestNotification(payload)
toast.success(`Sent: ${label}`)
} catch (err: any) {
toast.error(err.message || 'Failed')
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, 'Failed'))
} finally {
setSending(null)
}
@@ -65,7 +66,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
}: {
id: string; label: string; sub: string; icon: React.ElementType; color: string; onClick: () => void
}) => (
<button
<button type="button"
onClick={onClick}
disabled={sending !== null}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full border-edge bg-surface-card"
@@ -231,7 +231,7 @@ describe('GitHubPanel', () => {
render(<GitHubPanel />);
await screen.findByText('v1.7.0');
await user.click(screen.getByText('Show details'));
await screen.findByText('This is a plain paragraph without any markdown syntax.');
expect(await screen.findByText('This is a plain paragraph without any markdown syntax.')).toBeInTheDocument();
});
it('FE-ADMIN-GH-014: markdown link with safe href renders as anchor', async () => {
+340 -215
View File
@@ -1,146 +1,187 @@
import { useState, useEffect } from 'react'
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee, Bug, Lightbulb, BookOpen } from 'lucide-react'
import { getLocaleForLanguage, useTranslation } from '../../i18n'
import apiClient from '../../api/client'
import {
BookOpen,
Bug,
Calendar,
ChevronDown,
ChevronUp,
Coffee,
ExternalLink,
Heart,
Lightbulb,
Loader2,
Tag,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import apiClient from '../../api/client';
import { getLocaleForLanguage, useTranslation } from '../../i18n';
const REPO = 'mauriceboe/TREK'
const PER_PAGE = 10
const REPO = 'liketrek/TREK';
const PER_PAGE = 10;
interface GithubRelease {
id: number
prerelease: boolean
tag_name: string
name: string | null
body: string | null
published_at: string | null
created_at: string
author: { login: string } | null
[key: string]: unknown
id: number;
prerelease: boolean;
tag_name: string;
name: string | null;
body: string | null;
published_at: string | null;
created_at: string;
author: { login: string } | null;
[key: string]: unknown;
}
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
const { t, language } = useTranslation()
const [releases, setReleases] = useState<GithubRelease[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const { t, language } = useTranslation();
const [releases, setReleases] = useState<GithubRelease[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const fetchReleases = async (pageNum = 1, append = false) => {
try {
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
const data = Array.isArray(res.data) ? res.data : []
setReleases(prev => append ? [...prev, ...data] : data)
setHasMore(data.length === PER_PAGE)
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } });
const data = Array.isArray(res.data) ? res.data : [];
setReleases((prev) => (append ? [...prev, ...data] : data));
setHasMore(data.length === PER_PAGE);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Unknown error')
setError(err instanceof Error ? err.message : 'Unknown error');
}
}
};
useEffect(() => {
setLoading(true)
fetchReleases(1).finally(() => setLoading(false))
}, [])
setLoading(true);
fetchReleases(1).finally(() => setLoading(false));
}, []);
const handleLoadMore = async () => {
const next = page + 1
setLoadingMore(true)
await fetchReleases(next, true)
setPage(next)
setLoadingMore(false)
}
const next = page + 1;
setLoadingMore(true);
await fetchReleases(next, true);
setPage(next);
setLoadingMore(false);
};
const toggleExpand = (id) => {
setExpanded(prev => ({ ...prev, [id]: !prev[id] }))
}
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
};
const formatDate = (dateStr) => {
const d = new Date(dateStr)
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' })
}
const d = new Date(dateStr);
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' });
};
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
const renderBody = (body) => {
if (!body) return null
const lines = body.split('\n')
const elements = []
let listItems = []
if (!body) return null;
const lines = body.split('\n');
const elements = [];
let listItems = [];
const flushList = () => {
if (listItems.length > 0) {
elements.push(
<ul key={`ul-${elements.length}`} className="space-y-1 my-2">
<ul key={`ul-${elements.length}`} className="my-2 space-y-1">
{listItems.map((item, i) => (
<li key={i} className="flex gap-2 text-xs text-content-muted">
<span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--text-faint)' }} />
<span
className="mt-1.5 h-1 w-1 flex-shrink-0 rounded-full"
style={{ background: 'var(--text-faint)' }}
/>
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
</li>
))}
</ul>
)
listItems = []
);
listItems = [];
}
}
};
const escapeHtml = (str) => str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
const escapeHtml = (str) =>
str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const inlineFormat = (text) => {
return escapeHtml(text)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
.replace(
/`(.+?)`/g,
'<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>'
)
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#'
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`
})
}
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#';
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`;
});
};
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) { flushList(); continue }
const trimmed = line.trim();
if (!trimmed) {
flushList();
continue;
}
if (trimmed.startsWith('### ')) {
flushList()
flushList();
elements.push(
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1 text-content">
<h4 key={elements.length} className="mb-1 mt-3 text-xs font-semibold text-content">
{trimmed.slice(4)}
</h4>
)
);
} else if (trimmed.startsWith('## ')) {
flushList()
flushList();
elements.push(
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1 text-content">
<h3 key={elements.length} className="mb-1 mt-3 text-sm font-semibold text-content">
{trimmed.slice(3)}
</h3>
)
);
} else if (/^[-*] /.test(trimmed)) {
listItems.push(trimmed.slice(2))
listItems.push(trimmed.slice(2));
} else {
flushList()
flushList();
elements.push(
<p key={elements.length} className="text-xs my-1 text-content-muted"
<p
key={elements.length}
className="my-1 text-xs text-content-muted"
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
/>
)
);
}
}
flushList()
return elements
}
flushList();
return elements;
};
return (
<div className="space-y-3">
{/* Support cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<a
href="https://ko-fi.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ff5e5b';
e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div className="bg-[#ff5e5b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<div
className="bg-[#ff5e5b15]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Coffee size={20} className="text-[#ff5e5b]" />
</div>
<div>
@@ -153,11 +194,28 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
href="https://buymeacoffee.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ffdd00';
e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div className="bg-[#ffdd0015]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<div
className="bg-[#ffdd0015]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Heart size={20} className="text-[#ffdd00]" />
</div>
<div>
@@ -170,12 +228,31 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
href="https://discord.gg/NhZBDSd4qW"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#5865F2';
e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div className="bg-[#5865F215]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
<div
className="bg-[#5865F215]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
</div>
<div>
<div className="text-sm font-semibold text-content">Discord</div>
@@ -185,16 +262,33 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
</a>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<a
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
href="https://github.com/liketrek/TREK/issues/new?template=bug_report.yml"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ef4444';
e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div className="bg-[#ef444415]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<div
className="bg-[#ef444415]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Bug size={20} className="text-[#ef4444]" />
</div>
<div>
@@ -204,14 +298,31 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a>
<a
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
href="https://github.com/liketrek/TREK/discussions/new?category=feature-requests"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#f59e0b';
e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div className="bg-[#f59e0b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<div
className="bg-[#f59e0b15]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Lightbulb size={20} className="text-[#f59e0b]" />
</div>
<div>
@@ -221,14 +332,31 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a>
<a
href="https://github.com/mauriceboe/TREK/wiki"
href="https://github.com/liketrek/TREK/wiki"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#6366f1';
e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div className="bg-[#6366f115]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<div
className="bg-[#6366f115]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<BookOpen size={20} className="text-[#6366f1]" />
</div>
<div>
@@ -241,137 +369,134 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
{/* Loading / Error / Releases */}
{loading ? (
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
<div className="p-8 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-content-muted" />
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
<div className="flex items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin text-content-muted" />
</div>
</div>
) : error ? (
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
<div className="p-6 text-center">
<p className="text-sm text-content-muted">{t('admin.github.error')}</p>
<p className="text-xs mt-1 text-content-faint">{error}</p>
<p className="mt-1 text-xs text-content-faint">{error}</p>
</div>
</div>
) : (
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
<div className="px-5 py-4 border-b flex items-center justify-between border-edge-secondary">
<div>
<h2 className="font-semibold text-content">{t('admin.github.title')}</h2>
<p className="text-xs mt-0.5 text-content-faint">{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
<div className="flex items-center justify-between border-b border-edge-secondary px-5 py-4">
<div>
<h2 className="font-semibold text-content">{t('admin.github.title')}</h2>
<p className="mt-0.5 text-xs text-content-faint">{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
</div>
<a
href={`https://github.com/${REPO}/releases`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-lg bg-surface-secondary px-3 py-1.5 text-xs font-medium text-content-muted transition-colors"
>
<ExternalLink size={12} />
GitHub
</a>
</div>
<a
href={`https://github.com/${REPO}/releases`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
>
<ExternalLink size={12} />
GitHub
</a>
</div>
{/* Timeline */}
<div className="px-5 py-4">
<div className="relative">
{/* Timeline line */}
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
{/* Timeline */}
<div className="px-5 py-4">
<div className="relative">
{/* Timeline line */}
<div
className="absolute bottom-3 left-[11px] top-3 w-px"
style={{ background: 'var(--border-primary)' }}
/>
<div className="space-y-0">
{(isPrerelease ? releases : releases.filter(r => !r.prerelease)).map((release, idx) => {
const isLatest = idx === 0
const isExpanded = expanded[release.id]
<div className="space-y-0">
{(isPrerelease ? releases : releases.filter((r) => !r.prerelease)).map((release, idx) => {
const isLatest = idx === 0;
const isExpanded = expanded[release.id];
return (
<div key={release.id} className="relative pl-8 pb-5">
{/* Timeline dot */}
<div
className="absolute left-0 top-1 w-[23px] h-[23px] rounded-full flex items-center justify-center border-2"
style={{
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
}}
>
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
</div>
{/* Release content */}
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-content">
{release.tag_name}
</span>
{isLatest && (
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[rgba(34,197,94,0.12)] text-[#16a34a]">
{t('admin.github.latest')}
</span>
)}
{release.prerelease && (
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[rgba(245,158,11,0.12)] text-[#d97706]">
{t('admin.github.prerelease')}
</span>
)}
return (
<div key={release.id} className="relative pb-5 pl-8">
{/* Timeline dot */}
<div
className="absolute left-0 top-1 flex h-[23px] w-[23px] items-center justify-center rounded-full border-2"
style={{
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
}}
>
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
</div>
{release.name && release.name !== release.tag_name && (
<p className="text-xs font-medium mt-0.5 text-content-muted">
{release.name}
</p>
)}
<div className="flex items-center gap-3 mt-1">
<span className="flex items-center gap-1 text-[11px] text-content-faint">
<Calendar size={10} />
{formatDate(release.published_at || release.created_at)}
</span>
{release.author && (
<span className="text-[11px] text-content-faint">
{t('admin.github.by')} {release.author.login}
</span>
)}
</div>
{/* Expandable body */}
{release.body && (
<div className="mt-2">
<button
onClick={() => toggleExpand(release.id)}
className="flex items-center gap-1 text-[11px] font-medium transition-colors text-content-muted"
>
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
</button>
{isExpanded && (
<div className="mt-2 p-3 rounded-lg bg-surface-secondary">
{renderBody(release.body)}
</div>
{/* Release content */}
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-content">{release.tag_name}</span>
{isLatest && (
<span className="rounded-full bg-[rgba(34,197,94,0.12)] px-2 py-0.5 text-[10px] font-semibold text-[#16a34a]">
{t('admin.github.latest')}
</span>
)}
{release.prerelease && (
<span className="rounded-full bg-[rgba(245,158,11,0.12)] px-2 py-0.5 text-[10px] font-semibold text-[#d97706]">
{t('admin.github.prerelease')}
</span>
)}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
{/* Load more */}
{hasMore && (
<div className="text-center pt-2">
<button
onClick={handleLoadMore}
disabled={loadingMore}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
>
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
</button>
{release.name && release.name !== release.tag_name && (
<p className="mt-0.5 text-xs font-medium text-content-muted">{release.name}</p>
)}
<div className="mt-1 flex items-center gap-3">
<span className="flex items-center gap-1 text-[11px] text-content-faint">
<Calendar size={10} />
{formatDate(release.published_at || release.created_at)}
</span>
{release.author && (
<span className="text-[11px] text-content-faint">
{t('admin.github.by')} {release.author.login}
</span>
)}
</div>
{/* Expandable body */}
{release.body && (
<div className="mt-2">
<button type="button"
onClick={() => toggleExpand(release.id)}
className="flex items-center gap-1 text-[11px] font-medium text-content-muted transition-colors"
>
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
</button>
{isExpanded && (
<div className="mt-2 rounded-lg bg-surface-secondary p-3">{renderBody(release.body)}</div>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Load more */}
{hasMore && (
<div className="pt-2 text-center">
<button type="button"
onClick={handleLoadMore}
disabled={loadingMore}
className="inline-flex items-center gap-2 rounded-lg bg-surface-secondary px-4 py-2 text-xs font-medium text-content-muted transition-colors"
>
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
</button>
</div>
)}
</div>
</div>
</div>
)}
</div>
)
);
}
@@ -1,5 +1,5 @@
// FE-ADMIN-PKG-001 to FE-ADMIN-PKG-020
import { render, screen, waitFor } from '../../../tests/helpers/render';
// FE-ADMIN-PKG-001 to FE-ADMIN-PKG-032
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -18,6 +18,23 @@ beforeEach(() => {
resetAllStores();
});
/** Template rows carry [chevron, name, edit, delete]; category headers [add item, edit, delete]. */
function rowButtons(name: string, selector: string): HTMLElement[] {
const row = screen.getByText(name).closest(selector) as HTMLElement;
return within(row).getAllByRole('button');
}
const templateButtons = (name: string) => rowButtons(name, '.px-5.py-3');
const categoryButtons = (name: string) => rowButtons(name, '.bg-slate-50');
const itemButtons = (name: string) => rowButtons(name, '.group');
/** Expands the single fixture template and waits for its content. */
async function expandBeachTrip(user: ReturnType<typeof userEvent.setup>, firstChild: string) {
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText(firstChild);
}
describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-001: shows loading spinner on mount', async () => {
server.use(
@@ -233,7 +250,7 @@ describe('PackingTemplateManager', () => {
await user.click(screen.getByText('Add category'));
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
await user.type(catInput, 'Electronics{Enter}');
await screen.findByText('Electronics');
expect(await screen.findByText('Electronics')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-012: adding an item to a category', async () => {
@@ -263,7 +280,7 @@ describe('PackingTemplateManager', () => {
await user.type(itemInput, 'Sandals');
// Submit via Enter key (the input's onKeyDown handler triggers handleAddItem)
await user.type(itemInput, '{Enter}');
await screen.findByText('Sandals');
expect(await screen.findByText('Sandals')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-013: renaming a category inline updates its name', async () => {
@@ -295,7 +312,7 @@ describe('PackingTemplateManager', () => {
const catInput = screen.getByDisplayValue('Clothing');
await user.clear(catInput);
await user.type(catInput, 'Shoes{Enter}');
await screen.findByText('Shoes');
expect(await screen.findByText('Shoes')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-014: deleting a category removes it and its items', async () => {
@@ -362,7 +379,7 @@ describe('PackingTemplateManager', () => {
const input = screen.getByDisplayValue('T-shirt');
await user.clear(input);
await user.type(input, 'Tank Top{Enter}');
await screen.findByText('Tank Top');
expect(await screen.findByText('Tank Top')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-016: deleting an item removes it from the list', async () => {
@@ -508,4 +525,296 @@ describe('PackingTemplateManager', () => {
expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument()
);
});
it('FE-ADMIN-PKG-021: a failing template list toasts and shows the empty state', async () => {
server.use(http.get('/api/admin/packing-templates', () => HttpResponse.error()));
render(<><ToastContainer /><PackingTemplateManager /></>);
expect(await screen.findByText('Failed to load templates')).toBeInTheDocument();
expect(screen.getByText('No templates created yet')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-022: a failing expand toasts and leaves the template without content', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.error()),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
expect(await screen.findByText('Failed to load templates')).toBeInTheDocument();
expect(screen.getByText('Add category')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-023: an empty name is not submitted and a failing create toasts', async () => {
const user = userEvent.setup();
let posts = 0;
server.use(
http.post('/api/admin/packing-templates', () => {
posts += 1;
return HttpResponse.json({ error: 'nope' }, { status: 500 });
}),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await screen.findByText('No templates created yet');
await user.click(screen.getByRole('button', { name: /new template/i }));
const input = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)');
await user.type(input, ' {Enter}');
expect(posts).toBe(0);
await user.clear(input);
await user.type(input, 'Ski trip{Enter}');
expect(await screen.findByText('Failed to create template')).toBeInTheDocument();
expect(posts).toBe(1);
});
it('FE-ADMIN-PKG-024: deleting the expanded template collapses it, a failing delete toasts', async () => {
const user = userEvent.setup();
let calls = 0;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
http.delete('/api/admin/packing-templates/1', () => {
calls += 1;
return calls === 1
? HttpResponse.json({ error: 'in use' }, { status: 500 })
: HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'Clothing');
await user.click(templateButtons('Beach Trip')[3]);
expect(await screen.findByText('Failed to delete template')).toBeInTheDocument();
expect(screen.getByText('Clothing')).toBeInTheDocument();
await user.click(templateButtons('Beach Trip')[3]);
await screen.findByText('Template deleted');
await waitFor(() => expect(screen.queryByText('Clothing')).not.toBeInTheDocument());
expect(screen.getByText('No templates created yet')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-025: a blank rename closes the editor, a failing rename toasts, blur commits', async () => {
const user = userEvent.setup();
let puts = 0;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.put('/api/admin/packing-templates/1', () => {
puts += 1;
return HttpResponse.json({ error: 'nope' }, { status: 500 });
}),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await screen.findByText('Beach Trip');
await user.click(templateButtons('Beach Trip')[2]);
await user.clear(screen.getByDisplayValue('Beach Trip'));
await user.type(screen.getByRole('textbox'), '{Enter}');
await waitFor(() => expect(screen.getByText('Beach Trip')).toBeInTheDocument());
expect(puts).toBe(0);
// Blurring the field commits the pending name — here the request fails
await user.click(templateButtons('Beach Trip')[2]);
const input = screen.getByDisplayValue('Beach Trip');
await user.clear(input);
await user.type(input, 'Winter');
await user.tab();
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
expect(puts).toBe(1);
});
it('FE-ADMIN-PKG-026: a blank category is not posted, a failing add toasts and X cancels', async () => {
const user = userEvent.setup();
let posts = 0;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [], items: [] })),
http.post('/api/admin/packing-templates/1/categories', () => {
posts += 1;
return HttpResponse.json({ error: 'nope' }, { status: 500 });
}),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'Add category');
await user.click(screen.getByText('Add category'));
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
await user.type(catInput, ' {Enter}');
expect(posts).toBe(0);
await user.clear(catInput);
await user.type(catInput, 'Electronics{Enter}');
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
const cancel = within(catInput.parentElement as HTMLElement).getAllByRole('button')[1];
await user.click(cancel);
await waitFor(() =>
expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument(),
);
});
it('FE-ADMIN-PKG-027: a blank category rename closes the editor and a failing rename toasts', async () => {
const user = userEvent.setup();
let puts = 0;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [] })),
http.put('/api/admin/packing-templates/1/categories/10', () => {
puts += 1;
return HttpResponse.json({ error: 'nope' }, { status: 500 });
}),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'Clothing');
await user.click(categoryButtons('Clothing')[1]);
await user.clear(screen.getByDisplayValue('Clothing'));
await user.tab();
await waitFor(() => expect(screen.getByText('Clothing')).toBeInTheDocument());
expect(puts).toBe(0);
await user.click(categoryButtons('Clothing')[1]);
const catInput = screen.getByDisplayValue('Clothing');
await user.clear(catInput);
await user.type(catInput, 'Shoes{Enter}');
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
expect(puts).toBe(1);
});
it('FE-ADMIN-PKG-028: a failing category delete toasts and keeps the category', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
http.delete('/api/admin/packing-templates/1/categories/10', () => HttpResponse.error()),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'Clothing');
await user.click(categoryButtons('Clothing')[2]);
expect(await screen.findByText('Failed to delete category')).toBeInTheDocument();
expect(screen.getByText('Clothing')).toBeInTheDocument();
expect(screen.getByText('T-shirt')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-029: the add-item button posts the item, a failing add toasts and X closes the row', async () => {
const user = userEvent.setup();
let posts = 0;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [] })),
http.post('/api/admin/packing-templates/1/categories/10/items', () => {
posts += 1;
return posts === 1
? HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Sandals', sort_order: 0 } })
: HttpResponse.json({ error: 'nope' }, { status: 500 });
}),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'Clothing');
await user.click(categoryButtons('Clothing')[0]);
const itemInput = screen.getByPlaceholderText('Item name');
const addRow = itemInput.parentElement as HTMLElement;
expect(within(addRow).getAllByRole('button')[0]).toBeDisabled();
await user.type(itemInput, 'Sandals');
await user.click(within(addRow).getAllByRole('button')[0]);
await screen.findByText('Sandals');
await user.type(screen.getByPlaceholderText('Item name'), 'Towel');
await user.click(within(addRow).getAllByRole('button')[0]);
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
await user.click(within(addRow).getAllByRole('button')[1]);
await waitFor(() => expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument());
});
it('FE-ADMIN-PKG-030: the item editor commits on the check button, cancels on X and ignores a blank name', async () => {
const user = userEvent.setup();
let puts = 0;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
http.put('/api/admin/packing-templates/1/items/100', () => {
puts += 1;
return puts === 1
? HttpResponse.json({ success: true })
: HttpResponse.json({ error: 'nope' }, { status: 500 });
}),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'T-shirt');
// A blank name just closes the editor
await user.click(itemButtons('T-shirt')[0]);
const blank = screen.getByDisplayValue('T-shirt');
await user.clear(blank);
await user.click(within(blank.parentElement as HTMLElement).getAllByRole('button')[0]);
await waitFor(() => expect(screen.getByText('T-shirt')).toBeInTheDocument());
expect(puts).toBe(0);
// X discards the pending name
await user.click(itemButtons('T-shirt')[0]);
const editing = screen.getByDisplayValue('T-shirt');
await user.clear(editing);
await user.type(editing, 'Discarded');
await user.click(within(editing.parentElement as HTMLElement).getAllByRole('button')[1]);
await waitFor(() => expect(screen.getByText('T-shirt')).toBeInTheDocument());
expect(puts).toBe(0);
// The check button commits
await user.click(itemButtons('T-shirt')[0]);
const editing2 = screen.getByDisplayValue('T-shirt');
await user.clear(editing2);
await user.type(editing2, 'Tank Top');
await user.click(within(editing2.parentElement as HTMLElement).getAllByRole('button')[0]);
await screen.findByText('Tank Top');
expect(puts).toBe(1);
// A failing rename keeps the editor open and toasts
await user.click(itemButtons('Tank Top')[0]);
const editing3 = screen.getByDisplayValue('Tank Top');
await user.clear(editing3);
await user.type(editing3, 'Vest{Enter}');
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
expect(screen.getByDisplayValue('Vest')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-031: a failing item delete toasts and keeps the item', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
http.delete('/api/admin/packing-templates/1/items/100', () => HttpResponse.error()),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'T-shirt');
await user.click(itemButtons('T-shirt')[1]);
expect(await screen.findByText('Failed to delete item')).toBeInTheDocument();
expect(screen.getByText('T-shirt')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-032: the chevron button expands and collapses the template', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(templateButtons('Beach Trip')[0]);
await screen.findByText('Clothing');
await user.click(templateButtons('Beach Trip')[0]);
await waitFor(() => expect(screen.queryByText('Clothing')).not.toBeInTheDocument());
});
});
@@ -115,12 +115,13 @@ export default function PackingTemplateManager() {
await adminApi.deleteTemplateCategory(expandedId, catId)
setCategories(prev => prev.filter(c => c.id !== catId))
setItems(prev => prev.filter(i => i.category_id !== catId))
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
} catch { toast.error(t('admin.packingTemplates.deleteCategoryError')) }
}
// Item CRUD
const handleAddItem = async (catId: number) => {
if (!newItemName.trim() || !expandedId) return
// The name is already guaranteed non-empty by the button and the Enter handler.
if (!expandedId) return
try {
const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() })
setItems(prev => [...prev, data.item])
@@ -143,7 +144,7 @@ export default function PackingTemplateManager() {
try {
await adminApi.deleteTemplateItem(expandedId, itemId)
setItems(prev => prev.filter(i => i.id !== itemId))
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
} catch { toast.error(t('admin.packingTemplates.deleteItemError')) }
}
const inputStyle = 'w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent outline-none'
@@ -157,7 +158,7 @@ export default function PackingTemplateManager() {
<h2 className="font-semibold text-slate-900">{t('admin.packingTemplates.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.packingTemplates.subtitle')}</p>
</div>
<button onClick={() => setShowCreate(true)}
<button type="button" onClick={() => setShowCreate(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors">
<Plus className="w-4 h-4" /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
</button>
@@ -170,8 +171,8 @@ export default function PackingTemplateManager() {
<input autoFocus value={createName} onChange={e => setCreateName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateTemplate(); if (e.key === 'Escape') setShowCreate(false) }}
placeholder={t('admin.packingTemplates.namePlaceholder')} className={inputStyle} />
<button onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={16} /></button>
<button onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}><X size={16} /></button>
<button type="button" onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={16} /></button>
<button type="button" onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}><X size={16} /></button>
</div>
)}
@@ -186,7 +187,7 @@ export default function PackingTemplateManager() {
<div key={tmpl.id}>
{/* Template row */}
<div className="px-5 py-3 flex items-center gap-3 hover:bg-slate-50 transition-colors">
<button onClick={() => toggleExpand(tmpl.id)} className="text-slate-400 flex-shrink-0 p-0 bg-transparent border-none cursor-pointer">
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="text-slate-400 flex-shrink-0 p-0 bg-transparent border-none cursor-pointer">
{expandedId === tmpl.id ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
</button>
<Package size={16} className="text-slate-400 flex-shrink-0" />
@@ -196,14 +197,14 @@ export default function PackingTemplateManager() {
onKeyDown={e => { if (e.key === 'Enter') handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }}
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm" />
) : (
<span onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</span>
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-left bg-transparent border-0 p-0 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</button>
)}
<span className="text-xs text-slate-400 px-2 py-0.5 bg-slate-100 rounded-full">
{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}
</span>
<button onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
<button type="button" onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
className={`${btnIcon} hover:bg-slate-100 text-slate-400 hover:text-slate-700`}><Edit2 size={14} /></button>
<button onClick={() => handleDeleteTemplate(tmpl.id)}
<button type="button" onClick={() => handleDeleteTemplate(tmpl.id)}
className={`${btnIcon} hover:bg-red-50 text-slate-400 hover:text-red-500`}><Trash2 size={14} /></button>
</div>
@@ -227,11 +228,11 @@ export default function PackingTemplateManager() {
<span className="flex-1 text-xs font-bold text-slate-500 uppercase tracking-wider">{cat.name}</span>
)}
<span className="text-xs text-slate-400">{catItems.length}</span>
<button onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
<button type="button" onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Plus size={13} /></button>
<button onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
<button type="button" onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Edit2 size={13} /></button>
<button onClick={() => handleDeleteCategory(cat.id)}
<button type="button" onClick={() => handleDeleteCategory(cat.id)}
className={`${btnIcon} text-slate-400 hover:text-red-500`}><Trash2 size={13} /></button>
</div>
@@ -245,15 +246,15 @@ export default function PackingTemplateManager() {
<input autoFocus value={editItemName} onChange={e => setEditItemName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleRenameItem(item.id); if (e.key === 'Escape') setEditingItemId(null) }}
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
<button onClick={() => handleRenameItem(item.id)} className="p-1 text-slate-600 hover:text-slate-900"><Check size={13} /></button>
<button onClick={() => setEditingItemId(null)} className="p-1 text-slate-400"><X size={13} /></button>
<button type="button" onClick={() => handleRenameItem(item.id)} className="p-1 text-slate-600 hover:text-slate-900"><Check size={13} /></button>
<button type="button" onClick={() => setEditingItemId(null)} className="p-1 text-slate-400"><X size={13} /></button>
</>
) : (
<>
<span className="flex-1 text-sm text-slate-700">{item.name}</span>
<button onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
<button type="button" onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-slate-700 transition-all"><Edit2 size={12} /></button>
<button onClick={() => handleDeleteItem(item.id)}
<button type="button" onClick={() => handleDeleteItem(item.id)}
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 transition-all"><Trash2 size={12} /></button>
</>
)}
@@ -267,9 +268,9 @@ export default function PackingTemplateManager() {
onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }}
placeholder={t('admin.packingTemplates.itemName')}
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
<button onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
<button type="button" onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
className="p-1.5 rounded-lg bg-slate-900 text-white disabled:bg-slate-300 hover:bg-slate-700 transition-colors"><Plus size={13} /></button>
<button onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
<button type="button" onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
className="p-1 text-slate-400 hover:text-slate-600"><X size={13} /></button>
</div>
)}
@@ -286,11 +287,11 @@ export default function PackingTemplateManager() {
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
placeholder={t('admin.packingTemplates.categoryName')}
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm" />
<button onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={15} /></button>
<button onClick={() => { setAddingCategory(false); setNewCatName('') }} className={`${btnIcon} text-slate-400`}><X size={15} /></button>
<button type="button" onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={15} /></button>
<button type="button" onClick={() => { setAddingCategory(false); setNewCatName('') }} className={`${btnIcon} text-slate-400`}><X size={15} /></button>
</div>
) : (
<button onClick={() => setAddingCategory(true)}
<button type="button" onClick={() => setAddingCategory(true)}
className="flex items-center gap-2 px-3 py-2.5 w-full text-sm text-slate-400 hover:text-slate-600 border border-dashed border-slate-200 rounded-lg hover:border-slate-400 transition-colors">
<FolderPlus size={14} /> {t('admin.packingTemplates.addCategory')}
</button>
@@ -269,6 +269,6 @@ describe('PermissionsPanel', () => {
),
);
renderPanel();
await screen.findByText('Error');
expect(await screen.findByText('Error')).toBeInTheDocument();
});
});
@@ -104,7 +104,7 @@ export default function PermissionsPanel(): React.ReactElement {
<p className="text-xs text-slate-400 mt-0.5">{t('perm.subtitle')}</p>
</div>
<div className="flex items-center gap-2">
<button
<button type="button"
onClick={handleReset}
disabled={saving}
title={t('perm.resetDefaults')}
@@ -114,7 +114,7 @@ export default function PermissionsPanel(): React.ReactElement {
<RotateCcw className="w-3.5 h-3.5" />
<span className="hidden sm:inline">{t('perm.resetDefaults')}</span>
</button>
<button
<button type="button"
onClick={handleSave}
disabled={saving || !dirty}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:bg-slate-400 transition-colors"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,778 @@
import React, { useEffect, useRef, useState } from 'react'
import { Activity, FolderTree, HardDrive } from 'lucide-react'
import {
STORAGE_CATEGORIES,
type StorageBackend,
type StorageCategory,
type StorageConfig,
type StorageMigrationStatus,
type StorageTestResponse,
} from '@trek/shared'
import { useTranslation } from '../../../i18n'
import { formatBytes } from '../../../utils/formatBytes'
import { relativeTime } from '../../../utils/relativeTime'
import ConfirmDialog from '../../shared/ConfirmDialog'
import CustomSelect from '../../shared/CustomSelect'
import { useToast } from '../../shared/Toast'
import Section from '../../Settings/Section'
import BackendForm, { type BackendFormMirrorProps } from './BackendForm'
import {
CACHE_CATEGORIES,
adoptedMirrorFor,
computeMigrationCandidates,
effectiveCategoryMap,
foldBackends,
mirrorProbeTargets,
primaryNameOf,
removeBackend,
removeBackendAndMirrors,
renameBackendRefs,
replicaCandidates,
replicaOfPrimaries,
settingsDocumentOf,
setMirrorTargets,
stripCategories,
upsertBackend,
usageByBackend,
type FoldedBackendRow,
type MigrationCandidate,
} from './storageModel'
import { useStorageAdmin } from './useStorageAdmin'
/** Display-name mapper for joined category lists — the raw id renders only in the badge. */
const categoryNames = (t: (key: string) => string, ids: readonly string[]): string =>
ids.map((id) => t(`storage.category.${id}`)).join(', ')
function TestResult({ result, failedLabel, okLabel }: {
result: StorageTestResponse | undefined
failedLabel: string
okLabel: string
}): React.ReactElement | null {
if (result === undefined) return null
return (
<div className="mt-2 space-y-0.5">
<p className="text-xs font-semibold text-content">{result.ok ? okLabel : failedLabel}</p>
{result.targets.map((target) => (
<p key={target.name} className="text-xs text-content-faint">
{target.ok ? '✓' : '✗'} {target.name}
{target.error ? ` — ${target.error}` : ''}
</p>
))}
</div>
)
}
const LINK_BUTTON_STYLE: React.CSSProperties = { background: 'none', border: 'none', cursor: 'pointer' }
export default function AdminStoragePanel(): React.ReactElement {
const { t, locale } = useTranslation()
const toast = useToast()
const admin = useStorageAdmin(t('common.error'), t('storage.saveConflict'))
const [editing, setEditing] = useState<{
initial: StorageBackend | null
originalName: string | null
mirror: BackendFormMirrorProps
} | null>(null)
const [confirmRemove, setConfirmRemove] = useState<{ name: string; degenerate: boolean } | null>(null)
const [syncPrompt, setSyncPrompt] = useState<string | null>(null)
// Open/closed only — never a snapshot of candidates. Storing the candidate
// array here would go stale the moment the operator edits a category while
// the dialog is open; the render below and moveAndSave both recompute
// computeMigrationCandidates(draft, state) fresh instead.
const [migratePromptOpen, setMigratePromptOpen] = useState(false)
const [migrationQueue, setMigrationQueue] = useState<MigrationCandidate[]>([])
// Set by `save` right before it calls admin.save(): the pre-save mirror
// target count per row name. Consumed (and cleared) by the effect below the
// first time `admin.state` changes afterward — never touched otherwise, so
// unrelated state changes (the backfill poll included) are no-ops here.
const pendingPromptCheck = useRef<Map<string, number> | null>(null)
// Synchronous single-flight lock for the queue effect below: `admin.state`
// only reflects a just-started migration once startMigration's awaited
// refreshState() resolves, so `setMigrationQueue(rest)` re-firing the
// effect synchronously (still against the pre-POST `admin.state`) would
// otherwise dequeue and POST the next candidate before the server has
// confirmed the first — a ref (not state) so the guard is visible on that
// very next synchronous re-render, not just after a state-driven one.
const migrationStartInFlight = useRef(false)
useEffect(() => {
if (!pendingPromptCheck.current || !admin.state) return
const before = pendingPromptCheck.current
pendingPromptCheck.current = null
const { rows: afterRows } = foldBackends(admin.state, settingsDocumentOf(admin.state))
const grown = afterRows.find((r) => r.mirrorTargets.length > (before.get(r.name) ?? 0))
if (grown) setSyncPrompt(grown.name)
}, [admin.state])
// Queued category migrations run strictly sequentially: once a slot opens
// (no migration currently running), dequeue the next candidate and start
// it. Depends on admin.state so a poll landing a terminal status re-fires
// this without any user interaction. The in-flight lock closes the race
// where setMigrationQueue(rest) re-fires this effect synchronously while
// admin.state still shows the pre-POST world — without it, the next
// candidate could dequeue and POST before the first is server-confirmed.
// Also waits out a running BACKFILL — the server's one-storage-job-at-a-time
// rule spans backfills and migrations alike, so starting while one runs
// would 409, and the queued candidate would be lost (no retry).
//
// A failed startMigration POST would otherwise strand `rest`: it was
// already dequeued into local state before the POST, so on failure nothing
// ever changes `migrationQueue` or `admin.state` again — the effect never
// re-fires and the remaining candidates sit invisibly forever, silently
// never migrated. On failure the whole remaining queue is explicitly
// cleared (never retried — the operator re-triggers via Save), named in a
// toast, and admin.state is refreshed so the panel reflects whatever the
// failed attempt's category ended up as server-side.
useEffect(() => {
if (migrationQueue.length === 0 || !admin.state) return
if (migrationStartInFlight.current) return
if (admin.storageBusy()) return
const [next, ...rest] = migrationQueue
migrationStartInFlight.current = true
setMigrationQueue(rest)
void admin.startMigration(next!.category, next!.toWire).then((error) => {
migrationStartInFlight.current = false
if (error) {
toast.error(error)
if (rest.length > 0) {
toast.error(t('storage.migrate.queueDropped', { categories: categoryNames(t, rest.map((c) => c.category)) }))
setMigrationQueue([])
}
void admin.refreshState().catch(() => {})
}
})
}, [migrationQueue, admin.state])
if (admin.loading) {
return <p className="text-sm italic p-4 text-content-faint">{t('storage.loading')}</p>
}
if (!admin.state || !admin.draft) {
return (
<div role="alert" className="rounded-xl border p-4 text-sm border-edge bg-surface-card text-content">
{admin.loadError || t('common.error')}
</div>
)
}
const { state, draft } = admin
// Pure/cheap — recomputed every render so the migrate-prompt dialog (below)
// never shows a stale candidate set while it's open (fix: migration prompt
// staleness). moveAndSave recomputes independently at confirm time rather
// than reading this render-scoped value, since the click handler and the
// render that produced it are not guaranteed to be the same one.
const migrationCandidates = computeMigrationCandidates(draft, state)
// Duplicate pre-check needs EVERY wire name (hidden mirror names included);
// mirror-target candidates are the visible primaries only.
const backendNames = [...new Set([...state.backends.map((b) => b.name), ...draft.backends.map((b) => b.name)])]
const { rows, degenerate } = foldBackends(state, draft)
const effective = effectiveCategoryMap(state, draft)
const usageSums = usageByBackend(state, draft)
const startEdit = (row: FoldedBackendRow) => {
// Editing a built-in creates a settings override row bearing its name —
// the first-class relocation path (merge-by-name).
setEditing({
initial: row.backend,
originalName: row.name,
mirror: {
candidates: replicaCandidates(rows, row.name, row.mirrorTargets),
initialTargets: row.mirrorTargets,
},
})
}
const commitBackend = (backend: StorageBackend, mirrorTargets?: string[]) => {
const renamedFrom =
editing?.originalName && editing.originalName !== backend.name ? editing.originalName : null
// Widened to StorageConfig: these edit helpers are version-blind (they
// return plain StorageConfig), and admin.setDraft re-attaches the
// draft's own `version` regardless of what shape it's handed.
let next: StorageConfig | null = draft
if (renamedFrom) next = renameBackendRefs(removeBackend(next, renamedFrom), renamedFrom, backend.name)
next = upsertBackend(next, backend)
if (mirrorTargets !== undefined) next = setMirrorTargets(state, next, backend.name, mirrorTargets)
admin.setDraft(next)
setEditing(null)
}
const removeMessage = (name: string, isDegenerate: boolean): string => {
const row = rows.find((r) => r.name === name)
const assigned = isDegenerate
? degenerate.find((d) => d.backend.name === name)?.categories ?? []
: row?.categories ?? []
const usedAsReplicaBy = isDegenerate ? [] : replicaOfPrimaries(draft, name)
return [
t('storage.remove.body', { name }),
assigned.length > 0 ? t('storage.remove.stillAssigned', { categories: categoryNames(t, assigned) }) : '',
usedAsReplicaBy.length > 0 ? t('storage.remove.usedAsReplicaBy', { primaries: usedAsReplicaBy.join(', ') }) : '',
]
.filter(Boolean)
.join(' ')
}
const setCategory = (category: StorageCategory, primaryName: string) => {
// Picking a mirrored primary routes through its adopted mirror silently.
const target = adoptedMirrorFor(draft, primaryName)?.name ?? primaryName
// The admin state's category record is exhaustive by schema contract.
const stateEntry = state.categories[category]!
const categories = { ...draft.categories }
if (stateEntry.source === 'default' && target === stateEntry.backend) {
delete categories[category] // back to the default → no longer settings-owned
} else {
categories[category] = target
}
admin.setDraft({ ...draft, categories })
}
// Snapshot the LAST-CONFIRMED (pre-save `state`'s own fold, not the
// in-progress `draft`) mirror target counts; the effect above compares
// them against the fresh post-save state to detect newly-added
// replicas. Folding `draft` here would already include the unsaved
// edit and mask the very growth this is meant to detect.
const snapshotMirrorTargets = (): Map<string, number> => {
const { rows: beforeRows } = foldBackends(state, settingsDocumentOf(state))
return new Map(beforeRows.map((r) => [r.name, r.mirrorTargets.length]))
}
const doPlainSave = async () => {
pendingPromptCheck.current = snapshotMirrorTargets()
if (await admin.save()) toast.success(t('storage.saved'))
else pendingPromptCheck.current = null
}
// Recomputes the candidate set at confirm time — never trusts whatever was
// true when the dialog opened. The operator can edit categories (or a
// background poll can land a fresher usage scan) while the dialog is on
// screen; the recomputed set is what actually drives both stripCategories
// (what gets reverted from the PUT body) and the queue (what gets POSTed),
// so the two can never diverge from what the dialog most recently showed.
const moveAndSave = async () => {
const candidates = computeMigrationCandidates(draft, state)
pendingPromptCheck.current = snapshotMirrorTargets()
const ok = await admin.save(stripCategories(draft, state, candidates.map((c) => c.category)))
if (ok) {
setMigrationQueue(candidates)
toast.success(t('storage.saved'))
} else {
pendingPromptCheck.current = null
}
setMigratePromptOpen(false)
}
const routeOnlySave = async () => {
setMigratePromptOpen(false)
await doPlainSave()
}
const save = async () => {
if (migrationCandidates.length > 0) {
setMigratePromptOpen(true)
return
}
await doPlainSave()
}
const handleCancelMigration = async (m: StorageMigrationStatus) => {
const error = await admin.cancelMigration(m.category)
if (error) toast.error(error)
}
const testResultFor = (key: string): StorageTestResponse | 'running' | undefined => admin.testResults[key]
const handleRefreshStats = async () => {
const error = await admin.refreshStats()
if (error) toast.error(error)
}
const handleStartBackfill = async (row: FoldedBackendRow) => {
if (!row.mirrorName) return
setSyncPrompt((prev) => (prev === row.name ? null : prev))
const error = await admin.startBackfill(row.mirrorName)
if (error) toast.error(error)
}
const handleCancelBackfill = async (row: FoldedBackendRow) => {
if (!row.mirrorName) return
const error = await admin.cancelBackfill(row.mirrorName)
if (error) toast.error(error)
}
return (
<div>
{state.configError && (
<div
role="alert"
className="rounded-xl border p-3 mb-4 text-sm"
style={{ borderColor: 'var(--warning)', background: 'var(--warning-soft)', color: 'var(--text-primary)' }}
>
{t('storage.configError.banner', { error: state.configError })}
</div>
)}
<Section title={t('storage.health.title')} icon={Activity}>
{state.health.replicaFailures.length === 0 ? (
<p className="text-sm text-content-faint">{t('storage.health.allClear')}</p>
) : (
<ul className="space-y-1">
{state.health.replicaFailures.map((failure) => (
<li key={`${failure.backend}-${failure.key}-${failure.at}`} className="text-sm text-content">
{t('storage.health.failureLine', {
op: failure.op,
key: failure.key,
backend: failure.backend,
error: failure.error,
})}
<span className="text-content-faint"> · {relativeTime(failure.at, locale)}</span>
</li>
))}
</ul>
)}
{state.seedFilePresent && <p className="text-xs mt-2 text-content-faint">{t('storage.health.seedFile')}</p>}
</Section>
<Section title={t('storage.backends.title')} icon={HardDrive}>
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>{t('storage.description')}</p>
<p className="text-xs mb-2 text-content-faint">
{state.usage
? t('storage.usage.computed', { age: relativeTime(state.usage.computedAt, locale) })
: t('storage.usage.never')}
{' '}
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={handleRefreshStats}>
{state.usage ? t('storage.usage.refresh') : t('storage.usage.compute')}
</button>
</p>
<div className="space-y-3">
{rows.map((row) => {
const resultKey = row.mirrorName ?? row.name
const result = testResultFor(resultKey)
// row.mirrorName only exists when foldBackends adopted a draft mirror
// for this row, so the draft lookup below cannot miss.
const testCandidate = row.mirrorName
? draft.backends.find((b) => b.name === row.mirrorName)!
: row.backend
const rowUsage = usageSums?.[row.name]
const backfill = row.mirrorName ? state.backfills.find((b) => b.backend === row.mirrorName) : undefined
return (
<div key={row.name} data-testid={`storage-backend-${row.name}`} className="rounded-xl border p-4 border-edge-secondary">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-content">{row.name}</span>
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-secondary">
{t(`storage.type.${row.type}`)}
</span>
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-faint">
{t(`storage.source.${row.source}`)}
</span>
<span className="flex-1" />
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() =>
row.mirrorName
? admin.testMirror(resultKey, mirrorProbeTargets(draft, state, testCandidate))
: admin.test(testCandidate)
}
>
{t('storage.actions.test')}
</button>
{row.source !== 'env' && (
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => startEdit(row)}>
{t('storage.actions.edit')}
</button>
)}
{row.source === 'settings' && (
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: row.name, degenerate: false })}>
{t('storage.actions.remove')}
</button>
)}
</div>
<p className="text-xs mt-1 text-content-faint">
{row.categories.length > 0
? t('storage.backends.usedBy', { categories: categoryNames(t, row.categories) })
: t('storage.backends.unused')}
</p>
{rowUsage && (
<p className="text-xs mt-1 text-content-faint">
{t('storage.usage.line', { objects: String(rowUsage.objects), size: formatBytes(rowUsage.bytes) })}
{row.name === 'uploads-local' && state.usage!.legacyPhotos.objects > 0
? ` (${t('storage.usage.legacyNote')})`
: ''}
</p>
)}
{row.mirrorTargets.length > 0 && (
<p className="text-xs mt-1 text-content-faint">
{t('storage.mirror.mirroredTo', { targets: row.mirrorTargets.join(', ') })}
</p>
)}
{row.replicaOf.length > 0 && (
<p className="text-xs mt-1 text-content-faint">
{t('storage.mirror.replicaOf', { primaries: row.replicaOf.join(', ') })}
</p>
)}
{row.source === 'env' && <p className="text-xs mt-1 text-content-faint">{t('storage.backends.envReadOnly')}</p>}
{result === 'running' ? (
<p className="text-xs mt-2 text-content-faint">{t('storage.test.running')}</p>
) : (
<TestResult
result={result as StorageTestResponse | undefined}
okLabel={t('storage.test.ok')}
failedLabel={t('storage.test.failed')}
/>
)}
{row.mirrorTargets.length > 0 && (
<div className="mt-2">
{backfill?.status === 'running' ? (
<>
<p className="text-xs text-content-faint">
{t('storage.sync.running', { done: String(backfill.done), total: String(backfill.total) })}
</p>
<p className="text-xs text-content-faint">
{t('storage.sync.counts', {
copied: String(backfill.copied),
skipped: String(backfill.skipped),
failed: String(backfill.failed),
})}
</p>
<button type="button"
className="text-xs underline text-content-secondary mt-1"
style={LINK_BUTTON_STYLE}
onClick={() => handleCancelBackfill(row)}
>
{t('storage.sync.cancel')}
</button>
</>
) : (
<>
{/* Terminal statuses render ALONGSIDE the button (not instead-of) —
a completed/cancelled/errored backfill must stay re-runnable
without a page reload. */}
{backfill?.status === 'done' && (
<p className="text-xs text-content-faint">
{t('storage.sync.done', {
copied: String(backfill.copied),
deleted: String(backfill.deleted),
failed: String(backfill.failed),
})}
</p>
)}
{backfill?.status === 'cancelled' && (
<p className="text-xs text-content-faint">{t('storage.sync.cancelled')}</p>
)}
{backfill?.status === 'error' && (
<p className="text-xs text-content-faint">
{t('storage.sync.error', { error: backfill.error ?? '' })}
</p>
)}
{syncPrompt !== row.name && (
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => handleStartBackfill(row)}
>
{t('storage.sync.now')}
</button>
)}
</>
)}
</div>
)}
{syncPrompt === row.name && (
<div className="mt-2 rounded-lg border p-2 border-edge-secondary">
<p className="text-xs text-content">{t('storage.sync.prompt')}</p>
<div className="flex items-center gap-3 mt-1">
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => handleStartBackfill(row)}
>
{t('storage.sync.now')}
</button>
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => setSyncPrompt(null)}
>
{t('storage.sync.dismiss')}
</button>
</div>
</div>
)}
</div>
)
})}
{degenerate.map(({ backend, reason }) => {
const result = testResultFor(backend.name)
const primary = backend.type === 'mirror' ? backend.options.primary : ''
return (
<div key={backend.name} data-testid={`storage-backend-${backend.name}`} className="rounded-xl border p-4 border-edge-secondary">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-content">{backend.name}</span>
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-secondary">
{t('storage.type.mirror')}
</span>
<span className="flex-1" />
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => admin.test(backend)}>
{t('storage.actions.test')}
</button>
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: backend.name, degenerate: true })}>
{t('storage.actions.remove')}
</button>
</div>
<p className="text-xs mt-1 text-content-faint">{t(`storage.mirror.degenerate.${reason}`, { primary })}</p>
{result === 'running' ? (
<p className="text-xs mt-2 text-content-faint">{t('storage.test.running')}</p>
) : (
<TestResult
result={result as StorageTestResponse | undefined}
okLabel={t('storage.test.ok')}
failedLabel={t('storage.test.failed')}
/>
)}
</div>
)
})}
</div>
{state.migrations.length > 0 && (
<div className="space-y-3 mt-3">
{state.migrations.map((m) => (
<div
key={m.category}
data-testid={`storage-migration-${m.category}`}
className="rounded-xl border p-4 border-edge-secondary"
>
<p className="text-sm font-semibold text-content">{t(`storage.category.${m.category}`)}</p>
{m.status === 'running' ? (
<>
<p className="text-xs mt-1 text-content-faint">
{t('storage.migrate.running', {
category: t(`storage.category.${m.category}`),
done: String(m.done),
total: String(m.total),
})}
</p>
<button type="button"
className="text-xs underline text-content-secondary mt-1"
style={LINK_BUTTON_STYLE}
onClick={() => void handleCancelMigration(m)}
>
{t('storage.migrate.cancel')}
</button>
</>
) : m.status === 'done' ? (
<>
<p className="text-xs mt-1 text-content-faint">
{t('storage.migrate.done', { copied: String(m.copied), skipped: String(m.skipped) })}
</p>
{m.failed > 0 && (
<p className="text-xs mt-1 text-content-faint">
{t('storage.migrate.doneFailures', { failed: String(m.failed) })}
</p>
)}
{m.reclaimable && (
<p className="text-xs mt-1 text-content-faint">
{t('storage.migrate.reclaimable', {
objects: String(m.reclaimable.objects),
size: formatBytes(m.reclaimable.bytes),
from: m.from,
})}
</p>
)}
</>
) : m.status === 'failed' ? (
<p className="text-xs mt-1 text-content-faint">
{t('storage.migrate.failed', { error: m.error ?? '' })}
</p>
) : (
<p className="text-xs mt-1 text-content-faint">{t('storage.migrate.cancelled')}</p>
)}
</div>
))}
</div>
)}
{editing ? (
<BackendForm
initial={editing.initial}
backendNames={backendNames}
mirror={editing.mirror}
onCommit={commitBackend}
onCancel={() => setEditing(null)}
/>
) : (
<button type="button"
onClick={() =>
setEditing({
initial: null,
originalName: null,
mirror: { candidates: replicaCandidates(rows, null), initialTargets: [] },
})
}
style={{
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: '2px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
}}
>
{t('storage.backends.add')}
</button>
)}
</Section>
<Section title={t('storage.categories.title')} icon={FolderTree}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
{STORAGE_CATEGORIES.map((category) => {
// The admin state's category record is exhaustive by schema contract.
const stateEntry = state.categories[category]!
const selectedPrimary = primaryNameOf(state, draft, effective[category])
const changed = selectedPrimary !== primaryNameOf(state, draft, stateEntry.backend)
const viaMirror = effective[category] !== selectedPrimary
return (
<div key={category} data-testid={`storage-category-${category}`}>
<label className="block text-sm font-medium mb-1 text-content-secondary">
{t(`storage.category.${category}`)}
<span className="ml-2 px-1.5 py-0.5 rounded border font-mono font-normal text-xs border-edge text-content-faint">
{category}
</span>
</label>
<p className="text-xs mb-1.5 text-content-faint">{t(`storage.categoryDesc.${category}`)}</p>
{state.usage?.categories[category] && (
<p className="text-xs mb-1.5 text-content-faint">
{t('storage.usage.line', {
objects: String(state.usage.categories[category]!.objects),
size: formatBytes(state.usage.categories[category]!.bytes),
})}
</p>
)}
<CustomSelect
value={selectedPrimary}
onChange={(value) => setCategory(category, String(value))}
options={rows.map((row) => ({
value: row.name,
label:
stateEntry.source === 'default' && row.name === stateEntry.backend
? `${row.name} (${t('storage.categories.default')})`
: row.name,
}))}
size="sm"
style={{ maxWidth: 320 }}
/>
{changed && (
<p role="alert" className="text-xs mt-1 text-content-faint">
{t('storage.categories.reassignWarning')}
</p>
)}
{viaMirror && CACHE_CATEGORIES.includes(category) && (
<p role="note" className="text-xs mt-1 text-content-faint">
{t('storage.mirror.cacheWarning')}
</p>
)}
</div>
)
})}
</div>
</Section>
<div className="flex items-center gap-3">
<button type="button"
onClick={save}
disabled={!admin.dirty || admin.saving}
style={{
padding: '10px 20px', borderRadius: 10, cursor: admin.dirty && !admin.saving ? 'pointer' : 'default',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600,
border: '2px solid var(--text-primary)', background: 'var(--bg-hover)', color: 'var(--text-primary)',
opacity: admin.dirty && !admin.saving ? 1 : 0.5,
}}
>
{t('storage.save')}
</button>
{admin.dirty && <span className="text-xs text-content-faint">{t('storage.unsaved')}</span>}
</div>
{migrationQueue.length > 0 && (
<p className="text-xs mt-2 text-content-faint">
{t('storage.migrate.queued', { categories: categoryNames(t, migrationQueue.map((c) => c.category)) })}
</p>
)}
{migratePromptOpen && (
<div className="rounded-lg border px-3 py-2 mt-2 border-edge bg-surface-secondary" role="alertdialog">
<p className="text-sm text-content">{t('storage.migrate.promptTitle')}</p>
{migrationCandidates.map((c) => (
<p key={c.category} className="text-xs mt-1 text-content-secondary">
{c.objects === null
? t('storage.migrate.promptLineUnknown', {
category: t(`storage.category.${c.category}`),
from: c.from,
to: c.to,
})
: t('storage.migrate.promptLine', {
category: t(`storage.category.${c.category}`),
objects: String(c.objects),
size: formatBytes(c.bytes ?? 0),
from: c.from,
to: c.to,
})}
</p>
))}
<div className="flex items-center gap-3 mt-2">
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => void moveAndSave()}
>
{t('storage.migrate.move')}
</button>
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => void routeOnlySave()}
>
{t('storage.migrate.routeOnly')}
</button>
<button type="button"
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => setMigratePromptOpen(false)}
>
{t('storage.migrate.promptCancel')}
</button>
</div>
</div>
)}
{admin.saveError && (
<div role="alert" className="text-sm mt-2 text-content">
<p>{admin.saveError}</p>
{/* A 409 leaves the draft pinned to a version the server has moved
past; "save again" can never clear it, so the banner offers the
one action that can — see useStorageAdmin.discardDraft. */}
{admin.saveConflict && (
<button type="button"
className="text-xs underline text-content-secondary mt-1"
style={LINK_BUTTON_STYLE}
onClick={() => void admin.discardDraft()}
>
{t('storage.discardAndReload')}
</button>
)}
</div>
)}
<ConfirmDialog
isOpen={confirmRemove !== null}
onClose={() => setConfirmRemove(null)}
onConfirm={() => {
if (confirmRemove) {
admin.setDraft(
confirmRemove.degenerate
? removeBackend(draft, confirmRemove.name)
: removeBackendAndMirrors(state, draft, confirmRemove.name),
)
}
setConfirmRemove(null)
}}
title={t('storage.remove.title')}
message={confirmRemove ? removeMessage(confirmRemove.name, confirmRemove.degenerate) : ''}
confirmLabel={t('storage.remove.title')}
/>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More