Commit Graph

8 Commits

Author SHA1 Message Date
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 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 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 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 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 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 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