|
| 1 | +--- |
| 2 | +"@objectstack/types": minor |
| 3 | +"@objectstack/service-analytics": minor |
| 4 | +"@objectstack/runtime": minor |
| 5 | +"@objectstack/rest": patch |
| 6 | +--- |
| 7 | + |
| 8 | +fix(analytics,runtime,types): gate cube auto-inference on object existence; stop the dispatcher boundary returning raw SQL (#3867) |
| 9 | + |
| 10 | +Two independent defects on the `/analytics` surface, found while verifying #3770 |
| 11 | +against a real server. On an authenticated CRM dev server, before this change: |
| 12 | + |
| 13 | +``` |
| 14 | +POST /api/v1/analytics/query {"cube":"sqlite_master","measures":["count"],"dimensions":["type"]} |
| 15 | +→ 200 {"rows":[{"type":"index","count":262},{"type":"table","count":71},{"type":"view","count":1}], |
| 16 | + "sql":"SELECT type AS \"type\", COUNT(*) AS \"count\" FROM \"sqlite_master\" GROUP BY type"} |
| 17 | +``` |
| 18 | + |
| 19 | +That is SQLite's internal schema table — never a registered object — read |
| 20 | +successfully through the analytics endpoint. Not merely "the name reaches the |
| 21 | +driver and errors": **any table the connection can see was readable.** |
| 22 | + |
| 23 | +**① The cube name reached the driver as a table name.** `AnalyticsService.ensureCube` |
| 24 | +auto-infers a minimal Cube when none is registered, with `cube.sql = <the queried |
| 25 | +name>`. That is the intended "metric over an object" path — an `object-metric` KPI |
| 26 | +widget queries `crm_account` with no authored Cube — but it accepted *any* string, |
| 27 | +so the endpoint could aggregate over an arbitrary physical table. The |
| 28 | +analytics-side twin of the data-path gap #3770 closed, and it was not covered by |
| 29 | +that fix: #3770 gated the protocol's `analyticsQuery`, which is the *degraded |
| 30 | +fallback*; a deployment with `@objectstack/service-analytics` installed runs the |
| 31 | +real engine instead (`ctx.replaceService`). |
| 32 | + |
| 33 | +Inference is now gated on the same schema registry the data path consults, via a |
| 34 | +new optional `AnalyticsServiceConfig.isRegisteredObject` that `plugin.ts` wires |
| 35 | +from the `data` engine's `getObject`. Three-way rule: a registered Cube runs |
| 36 | +untouched (its `sql` is whatever it declares); an unregistered name that IS an |
| 37 | +object still auto-infers exactly as before; neither → `CUBE_NOT_FOUND` / 404 |
| 38 | +raised before any SQL exists, naming both ways to make the request valid. With no |
| 39 | +probe configured the gate stands down and warns once — the same tiering #3770 |
| 40 | +took for a missing registry. `generateSql` (`/analytics/sql`) is gated too. |
| 41 | + |
| 42 | +**② The dispatcher boundary returned `err.message` verbatim.** `errorResponseBase` |
| 43 | +is the single error exit for *every* route the dispatcher plugin mounts — |
| 44 | +`/analytics`, `/packages`, `/i18n`, `/storage`, `/automation`, `/auth`, |
| 45 | +`/notifications`, `/mcp`. `@objectstack/rest` has guarded its data routes against |
| 46 | +driver dumps forever (`mapDataError`); this boundary guarded nothing, so any |
| 47 | +driver error on any of those routes shipped its SQL to the client. Unlike ①, this |
| 48 | +half is unconditional — it does not depend on the cube being invalid. |
| 49 | + |
| 50 | +The leak heuristic moved out of `rest-server.ts` into `@objectstack/types` as |
| 51 | +`looksLikeInternalErrorLeak` (both packages already depend on it) and is now |
| 52 | +applied at both boundaries — one predicate, one place to widen when a new |
| 53 | +dialect's phrasing shows up. `mapDataError`'s behaviour is unchanged. At the |
| 54 | +dispatcher it applies **only to 5xx**: a 4xx message is a deliberate |
| 55 | +business/validation answer and must reach the caller intact. Sanitising costs no |
| 56 | +diagnostics — the untouched error still reaches `errorReporter` through the |
| 57 | +existing `__obsRecordedError` side-channel. |
| 58 | + |
| 59 | +**Also fixed in the same function:** `errorResponseBase` read only |
| 60 | +`err.statusCode`, while domain errors across this codebase carry `status` (and |
| 61 | +`HttpDispatcher.errorFromThrown` already reads `status` first). Every deliberate |
| 62 | +4xx thrown through a dispatcher route — including #3770's `OBJECT_NOT_FOUND` on |
| 63 | +the analytics fallback path — was rendered as a **500**. It now reads `status` |
| 64 | +then `statusCode`. |
| 65 | + |
| 66 | +**Behaviour change.** `/analytics/query` and `/analytics/sql` return 404 |
| 67 | +`CUBE_NOT_FOUND` for a cube that is neither registered nor a registered object; |
| 68 | +previously the name was passed to the driver. Dashboards and KPI widgets pointed |
| 69 | +at real objects or authored cubes are unaffected. A 5xx on a dispatcher route |
| 70 | +whose message looks like a driver dump now reads `Internal server error` — check |
| 71 | +server logs or your error reporter for the original. |
0 commit comments