Skip to content

feat(properties): health check, tracing, injection hardening and pagination for the Property API - #1473

Open
sadeeq6400 wants to merge 1 commit into
MettaChain:mainfrom
sadeeq6400:feature/propertyapi-health-tracing-hardening-pagination
Open

sadeeq6400 wants to merge 1 commit into
MettaChain:mainfrom
sadeeq6400:feature/propertyapi-health-tracing-hardening-pagination

Conversation

@sadeeq6400

Copy link
Copy Markdown

Operability and input hardening for the Property API (src/properties), following the Users API pass in #1457–#1463.

#1375 — Health check

GET /properties/health (unauthenticated) answers 200 when the database can read the properties table, and 503 when it cannot or does not answer within 2 seconds.

{ "status": "up", "checkedAt": "…", "checks": { "database": { "status": "up", "latencyMs": 3 } } }
  • The probe is a findFirst({ select: { id: true } }) — the cheapest read available that still proves the connection, the pool and the table are all usable.
  • The timeout is raced with Promise.race and the timer is always cleared in a finally, so a slow database cannot leak a handle on every probe.
  • PropertiesHealthController is registered before PropertiesController in the module, so GET /properties/health is matched by the probe and not swallowed by GET /properties/:id. This ordering is easy to get wrong and the Users API had to solve the same problem.
  • Error detail is never returned, so the probe cannot leak connection information.

Separate from the platform-wide /healthz and /readyz, which report the process as a whole.

→ docs/api/properties-health.md

#1376 — Tracing

PropertiesTraceInterceptor logs exactly one line per handler call:

[1f0c…] properties.create ok 143ms
[1f0c…] properties.transitionStatus failed status=400 BadRequestException 8ms

The trace id is the one set by the platform TraceInterceptor, else the inbound x-request-id, else untraced — so a single listing call is followable across the log stream with no client-side correlation work. Success is log, failure is warn with status and error class, so failures can be alerted on without parsing messages.

Bodies, headers, addresses and personal data are never logged. A listing create carries an owner's identity and a full address; that should not end up in log aggregation.

→ docs/api/properties-tracing.md

#1377 — Injection hardening

PropertyInjectionGuardPipe screens the write bodies (create, update, status transition, bulk, agent assignment, amenities) and the GET /properties/search query, answering 400 for:

  • keys starting with $ or containing . at any depth,
  • NUL bytes in any string,
  • SQL comment / stacked-statement markers (--, /*, ; drop|delete|update|insert|alter|truncate) in the free-text fields,
  • payloads nested deeper than 8 levels.

Why this is not already covered: the global ValidationPipe runs with whitelist: true, forbidNonWhitelisted: true, but that only rejects unknown keys at the top level of a body. It does not descend into values — a $-prefixed key inside a nested object or a dotted key inside an array element is not what whitelist inspects. The pipe walks the whole structure, array elements included (propertyIds[0], tags[2]).

Search is the highest-value target: it is the one unauthenticated route whose values build predicates, and location, category and tag all end up in contains/has filters.

Prisma parameterises every query, so a payload that got through would be bound rather than concatenated. This is the earlier layer, not a replacement.

→ docs/api/properties-injection-hardening.md

#1378 — Pagination

GET /properties accepts page (>= 1) and limit (1–100, default 20).

  • With neither parameter the response is the plain array it has always been — existing clients are unaffected.
  • With either, it becomes { data, meta: { page, limit, total, totalPages, hasNext } }.

total comes from a count() over the same where clause as the page, issued in the same $transaction as the rows, so the count and the page cannot disagree. findAll is left unchanged for existing callers and findAllPaginated is a new method alongside it; the shared "active and not deleted" filter and the owner/agent include payload were hoisted so the two cannot drift apart.

GET /properties/search already paged and already capped limit at 100, so it is untouched and keeps its existing response shape.

One behaviour change to be aware of: because the handler now takes a validated query DTO, an unknown query parameter on GET /properties is rejected with 400. That is consistent with GET /properties/search and with the global forbidNonWhitelisted: true, but it is new for this route.

The existing integration spec asserts GET /properties returns a bare array, which the no-parameters path still does.

→ docs/api/properties-pagination.md

Scope

Module-local additions plus one doc per issue. Nothing was installed, built, linted or tested — please verify in CI.

Closes #1375
Closes #1376
Closes #1377
Closes #1378

…nation

Operability and input hardening for the Property API (`src/properties`),
following the Users API pass in MettaChain#1457-MettaChain#1463.

- MettaChain#1375: GET /properties/health answers 200 when the database can read the
  properties table and 503 when it cannot or does not answer within 2s. The
  probe is a findFirst({ select: { id: true } }) so it is the cheapest read that
  still proves the connection, pool and table are usable; the timeout timer is
  always cleared. PropertiesHealthController is registered before
  PropertiesController so `GET /properties/health` is not captured by
  `GET /properties/:id`. No error detail is returned.
  docs/api/properties-health.md
- MettaChain#1376: PropertiesTraceInterceptor logs one line per handler call with the
  trace id from TraceInterceptor (or x-request-id, or `untraced`), the handler,
  the outcome and the duration. Bodies, headers, addresses and personal data are
  never logged. docs/api/properties-tracing.md
- MettaChain#1377: PropertyInjectionGuardPipe screens the write bodies and the search
  query for `$`-prefixed or dotted keys at any depth, NUL bytes, SQL
  comment/stacked-statement markers in the free-text fields, and payloads nested
  deeper than 8 levels. This is not redundant with the global ValidationPipe:
  `whitelist`/`forbidNonWhitelisted` only inspect the top level of a body, not
  keys reached inside a nested object or an array element.
  docs/api/properties-injection-hardening.md
- MettaChain#1378: GET /properties accepts page (>=1) and limit (1-100, default 20). With
  neither parameter the response is still the plain array it always was; with
  either it becomes { data, meta: { page, limit, total, totalPages, hasNext } }.
  The total comes from a count() on the same where clause, issued in the same
  $transaction as the rows so the two cannot disagree. `findAll` is unchanged for
  existing callers and `findAllPaginated` was added alongside it; the shared
  where-clause and include payload were hoisted so the two cannot drift apart.
  GET /properties/search already paged and already capped limit at 100, so it is
  untouched. docs/api/properties-pagination.md

One behaviour change worth calling out: because GET /properties now takes a
validated query DTO, an unknown query parameter there is rejected with 400. That
matches GET /properties/search and the global forbidNonWhitelisted setting.

The existing integration spec asserts `GET /properties` returns a bare array,
which the no-parameters path still does.

Nothing was installed, built, linted or tested; please verify in CI.

Closes MettaChain#1375
Closes MettaChain#1376
Closes MettaChain#1377
Closes MettaChain#1378
@drips-wave

drips-wave Bot commented Oct 18, 2026

Copy link
Copy Markdown

@sadeeq6400 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant