PERMISSION_ACTIONS, PermissionLevel, PermissionKey and evaluatePermission()
move to shared/src/permissions. PermissionsService decides through
evaluatePermission and re-exports the catalog for its importers, and
@RequirePermission takes a PermissionKey, so a misspelt action fails to
compile. Levels, defaults and every verdict stay as they were.
toMinor, sumMinor, splitEqualShares and currencyDecimals move to
shared/src/money. The budget service and its MCP tools call them instead of
private copies, and the share table both sides were pinned to becomes the
spec of the shared function. Stored values and every computed figure stay
the same.
Prettier on every new or moved file that was not formatted, so lint:format
holds them; the files taken off the format baseline are the ones that moved
or were formatted on the way.
The module comment no longer spells an @InjectRepository call the scanner took
for a foreign Users injection in nest/database, and journey's Users entry is
gone now that it reads the Immich flag through UserImmichRepository.
The reader left places.service with four strictNullChecks errors that a new
file may not carry. Declaring fetchPage's result as a page or a list import
error lets the 'error' check narrow it; the places and maps entries drop off
the strict baseline.
Non-null assertions become explicit guards, the two demo-gate loops collect
their answers with Promise.all, and the MapsService imports the maps tests no
longer use are gone. The src counts the refactors lowered are taken off the
warnings baseline.
McpToolGuardsService injects RealtimeService, and McpSharedModule counted on
some other module of the app having imported the global RealtimeModule.
AuthModule used to do that for every domain that imported it for the demo
check; with those imports gone, a test app assembling ShareModule alone no
longer resolved the guards. The module now names the dependency itself.
A domain injecting the repository of a table another domain owns couples
the two below the service layer, and nothing counted it. The new
foreignRepositories rule reads every @InjectRepository under src/nest,
looks the entity up in scripts/repository-owners.json (an injected entity
without an owner stops the run) and holds each domain/entity pair to the
baseline, which only shrinks: 209 pairs today.
Thirty services, controllers, MCP tools and RPC handlers outside the trips
domain injected TripsRepository for one call, findAccessible. They now
inject TripAccessService from the trip-membership leaf, in the same
constructor slot under the same field name; it answers with the same
access row (or undefined) and binds the id exactly as given, so each
caller keeps its own coercion and its own 404, 403 or tool error.
VacayMcp injected AuthService only to call getCurrentUser for the caller's
username and email before sending an invite or sharing the calendar, which
kept VacayModule importing AuthModule. UsersRepository.findUsernameEmail
answers the same two fields (and undefined for an unknown id, which the
tools already refuse as 'User not found.').
AddonsService read the transit backend through transit/transit-provider.ts
and built photo-provider endpoint paths with memories.helpers, so the addons
domain, which nearly every domain imports for its gates, depended on
transit and memories. Both are free functions over a repository or plain
strings; they move unchanged to common/, where the transit and admin sides
read them too.
The websocket gateway injected JourneyDomainService to decide who may join
a journey's book room, so the realtime domain, which nearly every domain
imports for its broadcasts, depended on journey and closed a cycle through
all of them. It now injects a JOURNEY_ACCESS port declared in realtime;
JourneyAccessModule (global, imported by AppModule beside the gateway
module) binds it to the same JourneyDomainService method.
The gateway imported hostVersion from the plugin install code to put the
version in its welcome frame, which made the realtime domain, imported by
nearly every other, depend on plugins. The running version (image VERSION
or APP_VERSION, else package.json) is app config; runningVersion() holds it
and the plugin host-compat checks call the same function.
NotificationsController, SettingsController, OidcController and the packing
template controller each imported their body DTO from admin/admin.dto.ts,
and AdminController imported the same four without using them. Those
imports tied the notifications, settings, packing and oidc domains to the
admin domain and through it to everything admin depends on. The classes
move unchanged (same names, same @trek/shared schemas) into each domain's
own dto file.
Every domain's *.rpc.ts was written against rpc-kit, rpc-params, rpc-errors
and PluginGuards, and every domain module imported PluginGuardsModule, all
of it inside src/nest/plugins, while PluginsRuntimeModule imports those same
domains so their handlers exist at boot. That made the plugins domain part
of every cycle. The kit imports no domain but the protocol tables and
permissions/addons for the guards, so it moves to src/nest-rpc beside
src/nest-mcp, with its tests, its README and its extraction rule; the
runtime module keeps its domain imports, which the boot-time coverage check
of the wire surface needs in any subset app.
JwtAuthGuard, OptionalJwtAuthGuard, CookieAuthGuard, AdminGuard, the global
and MFA guards, @CurrentUser, @Public, JWT verification and the ephemeral
token service lived in the auth domain, so every controller that checked a
session depended on the whole account module and its imports. They import
no domain themselves; under src/nest/auth-core they are shared kernel like
common/, every importer points there directly, and AtlasModule and
NotificationsModule stop importing AuthModule, which they only did for the
demo gate. lint:boundaries counts auth-core as kernel: 57 fewer cyclic
domain edges, the largest cycle shrinks from 46 to 40 domains.
Every write tool used to open with its own demo check, 196 copies across 30
tool files, most of them injecting AuthService for nothing else. The
registry now takes a toolGate (built from the container) that runs before
each registered tool handler, and TREK's gate refuses a demo account on
every tool not annotated read-only with the same canned result. The
per-tool checks, the AuthService injections, AuthService.isDemoUser and the
AuthModule imports that only fed them are gone; ESLint refuses a demo check
in a tool file, and a registry-wide test calls every write tool as the
demo account.
UserImmichRepository, UserSynologyRepository and UserAirtrailRepository hold the eighteen connection statements UsersRepository carried, on the same users table and with their statements unchanged, provided through a small module as factories over the EntityManager. ImmichService, SynologyService, AirtrailService and JourneyService inject them instead of the account repository. UserRow drops the dead immich_access_token column (the table keeps it), and a shape test pins the account repository's size.
app-settings.registry.ts lists every app_settings key the server reads by name with its shape, default, environment override and flags. The admin form's keys, its masked and encrypted handling and the public config's reads derive from it, isOidcConfigured() replaces the three issuer/client-id checks in AuthService, and OidcService resolves its values through the same rule while still requiring the client secret. Every literal getValue() read goes through readAppSetting()/resolveAppSetting(), and ESLint refuses a new one.
The login toggles' legacy fallback, the public config, the lockout guard of the settings save and the provider config each decide on their own whether OIDC is configured. These cases hold what each answers, including that only the provider config also wants the client secret, before the checks are shared.
The ICS export reads the stops and notes of all dated days in two statements instead of two per day, the journey's full read and entry list take the source trip titles in one join instead of one read per entry, and reordering a day's stops or sorting it by time writes every position in one CASE update. Results are unchanged: each day keeps its order (the id settles a tie), a missing trip still names null, and an id listed twice keeps its last position.
server/CLAUDE.md now says which API new repository code uses: Kysely for join, aggregate and set-based statements, the entity methods for single-table CRUD, no new QueryBuilder calls. The TrekRepository docstring no longer calls Kysely an escape hatch. lint:query-api counts this.qb()/createQueryBuilder() per repository file against a baseline that may only shrink and runs in the server CI job.
The same file documents the JSON column and timestamp rules from the two changes before.
decodeJson/decodeJsonResult/encodeJson read and write a JSON-in-TEXT column against the schema and fallback declared for it in db/json-columns.ts, and log a row that does not decode. reservations.metadata, the OAuth scope and redirect lists, consents, API token scopes, MFA backup codes, journey entry tags and pros/cons and poll options go through it. Readers that used to throw on a broken row (a hotel update, the ICS export, the price mirror, the OAuth token and session reads, a consent, a journey entry, a poll) now take the tolerant fallback the other readers of the same column already used.
A DbTimestamp brand, produced only by dbNow(), types the expiry writes of OAuth tokens and password reset links, and collection edits stamp updated_at the same way. parseDbTimestamp() reads the stored text as UTC and replaces the oauth-local parser; utcSuffix lives once beside it. A data migration rewrites the ISO values those writers left behind, and the OAuth session lists keep answering the expiries in ISO.
Two outputs change on purpose. collections.updated_at used to come back in two spellings: a collection never edited answered the column default (2026-01-02 03:04:05), an edited one the ISO text its writer stored (2026-01-02T03:04:05.678Z). It now answers the canonical text in every case, in the PUT and cover responses and, once the migration has run, in every read; the shared schema types it as a plain string and the client does not read it. The ISO expiries of the OAuth session lists are rebuilt from the stored second-precision text, so they end in .000Z instead of carrying the milliseconds of the write.
tsconfig.strict.json is the build config with strictNullChecks and
noImplicitAny on. lint:strict runs tsc over it and holds the errors per file
to scripts/strict-baseline.json (204 in 109 files today): a file may only
lose errors, a new file starts at none, and an entry above its count fails
until --update lowers it. An error outside any file or an unreadable tsc run
fails closed. It runs in the server job; the build itself stays on
strict: false.
shared's lint ran eslint --fix with prettier/prettier as an error, so in
lint-prettier.yml the format:check after it only ever saw files it had just
rewritten, and the server's format scripts pointed at a test/ directory that
does not exist. Now lint is check-only in both (lint:fix applies fixes), the
server scripts cover src/ and tests/, and a lint:format ratchet on the shared
library holds every file outside scripts/format-baseline.json to Prettier in
both workspaces: in the server job of test.yml and in lint-prettier.yml,
which keeps providing the lint check. Formatting leaves ESLint in shared, and
the unused eslint-plugin-prettier goes. Once the tree is reformatted in one
commit, --update empties the baselines and the check is a full prettier
--check.
Server lint ran without --max-warnings or a baseline, so the rules left on
warn could grow without limit. lint:warnings runs ESLint with the server's
own config through the shared tally, fails on any error or inline rule
config, and holds the warnings per rule for src, tests and eslint-disable'd
messages to scripts/eslint-baseline.json, which only falls. It replaces
lint:check in CI. no-await-in-loop, no-non-null-assertion and no-console come
in as warnings held at today's counts. npm run lint no longer rewrites files;
lint:fix does.
server/src/nest/README.md drops from 988 lines to under 200: the module
shape with weather as the reference, the cross-cutting pieces, the guards,
parity and the test layout, without the bridge playbook and the services
recipe that contradicted the code. The migration log moves verbatim to
server/docs/nest-migration-history.md, marked historical.
scripts/ci/doc-paths.mjs resolves every backtick path in the CLAUDE.md files,
the root README, the blueprint and the wiki against the tracked files. The
paths already missing are listed in a baseline that only shrinks, and the
changes job runs it so docs-only changes are checked too. The wiki no longer
says the Playwright flows stay out of CI.
None of their keys is read anywhere in client, server, shared or plugin-sdk,
neither literally nor through a key built from a prefix: the photo, sign-up
and stats screens they were written for are gone. All 27 locales lose the
same three files, so parity holds.
Thirteen interfaces nobody imported are gone. Trip is now the selectable row
of the generated TripsTable, so currency, is_archived and reminder_days read
as the nullable columns they are and feed_token and reminder_sent_for exist.
The two React profiler readers were one-off analysis helpers, and the i18n
migration read client/src/i18n/translations, which has been gone since the
locales moved to shared/.
Only their own tests imported them. The MCP knobs are parsed in
app-config/parsers.ts, which tests them directly, and toCamelCase was never
wired into a plugin RPC handler.