diff --git a/.changeset/27505-analytics-empty-cube.md b/.changeset/27505-analytics-empty-cube.md new file mode 100644 index 00000000000..6b9f6d9223b --- /dev/null +++ b/.changeset/27505-analytics-empty-cube.md @@ -0,0 +1,25 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-analytics': minor +'@objectstack/runtime': minor +--- + +fix(spec)!: `POST /analytics/query` and `POST /analytics/sql` refuse an empty cube name with 400 VALIDATION_FAILED, not 500, and the engine's in-process guard throws the same typed refusal (#27505) + +Clause-②: no (narrowing) — `AnalyticsQueryRequestSchema.cube`, the body of the two analytics query doors, refuses the empty string, which it admitted before as "a string". Nothing is widened and no key is added, removed or renamed. +Metadata-protocol: narrowed +Skills-docs: none — `skills/**` names a cube only by a real name (`skills/objectstack-ui/SKILL.md`, `rules/dashboards.md` and the `analytics-inline-vs-dataset` eval); no file teaches an empty `cube`, and every taught use stays true. + + + +**BREAKING** accept-set narrowing at the analytics query doors (`@objectstack/spec`), and the error shape of the engine's own guard (`@objectstack/service-analytics`). + +- **Before (`@objectstack/spec@18.2.0`, the last release).** `cube` was `z.string()`, so `{ "cube": "", "measures": ["x.count"] }` passed the body schema at `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql`, reached the engine, and the engine's guard threw a plain `Error` (`Cube name is required …`), which the door mapped to **500 `INTERNAL_ERROR`** — a client input mistake read as a server fault, paged operators and got retried. The same body with `cube` left out answered 400. +- **Now.** `cube` is `z.string({ error: … }).min(1, …)` (JSON Schema `minLength: 1`; the one sentence is the type-level `error` and the `.min(1)` message alike). An empty cube name is refused at the door, `400 VALIDATION_FAILED` with one `fields[]` entry `{ field: 'cube', code: 'min_length' }` reading the schema's prescription (`Name the cube to query: …`, exported as `ANALYTICS_CUBE_NAME_REQUIRED`), and the engine is never called. A missing `cube` keeps the 400 it already had, and now reads the same sentence (the member carries a type-level `error` beside its `.min(1)`) where it read Zod's `expected string, received undefined`; a present `cube` that is not a string reads the same sentence with code `invalid_type`. The field code is what tells the cases apart (`required` / `invalid_type` / `min_length`). +- **`@objectstack/runtime`.** The two doors now hand the body to the field mapper (`fieldsFromZodIssues(issues, body)`, as the `/data` doors do), so an ABSENT required key of the analytics body is reported with the ADR-0114 code `required` where it used to read Zod's `invalid_type` for the same body: `cube` left out, `measures` left out, and a request with no body at all (`field: '(body)'`). A present key of the wrong type still reads `invalid_type`. Status and `error.code` (`400 VALIDATION_FAILED`) are unchanged; only the per-field `fields[].code` moves. +- **`@objectstack/service-analytics`.** `AnalyticsService.query` and `generateSql` refuse an absent or empty `cube` with the same typed refusal — `code: 'VALIDATION_FAILED'`, `status: 400`, `name: 'ValidationError'`, the schema's own sentence as the message, and a `fields[]` entry at `cube` produced by running the value through the schema's `cube` member and the one D3 mapper, so its code is the door's for the same value (`required` absent, `min_length` empty, `invalid_type` for a `null`) — in place of the two plain `Error` throws, so a plugin, flow or test that holds the service reads the answer the door gives, and a boundary that serves the throw (`resolveThrownHttpError`, the dispatcher's error exits) answers 400 rather than 500. +- **Not covered.** `MemoryAnalyticsService` (`@objectstack/driver-memory`), the other `IAnalyticsService` implementation, keeps its plain `Error('Cube name is required')` for the same input in this release; this change does not touch it. At the HTTP doors the schema now refuses an empty cube before either implementation runs, so the difference reaches in-process callers only. +- **Unchanged.** A non-empty cube name parses and runs as before. A whitespace-only name is a name no cube carries and keeps its `404 CUBE_NOT_FOUND` from the engine; this change adds no trimming rule. Nothing is stored: a cube name travels in a request, never in stack metadata, so there is no row to rewrite. +- **Who sees it.** A dashboard, client or script that sends `cube: ""` — an unset selection or a blank form field — read a 500 before and reads a 400 with the field named now. A test that pinned the old plain-Error text (`Cube name is required`) pins the envelope instead. + +**The remedy.** Name the cube to query: a cube `GET /analytics/meta` lists, or a registered object name. There is no mechanical rewrite: an empty string names no cube, so the choice is the caller's. diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx index 30d0615ad73..c2ed54ee728 100644 --- a/content/docs/references/api/analytics.mdx +++ b/content/docs/references/api/analytics.mdx @@ -91,7 +91,7 @@ const result = AnalyticsEndpoint.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **cube** | `string` | ✅ | Target cube name | +| **cube** | `string` | ✅ | Target cube name — the cube to query, as `GET /analytics/meta` lists them, or a registered object name (read as an auto-inferred cube). Required and non-empty: an empty string names no cube and is refused at the schema. | | **measures** | `string[]` | ✅ | List of metrics to calculate | | **dimensions** | `string[]` | optional | List of dimensions to group by | | **where** | `any` | optional | Filtering criteria (canonical Query DSL FilterCondition). An authored `FilterArray` is lowered by `parseFilterAST` on the client before the wire; this field admits only the lowered `FilterCondition` (see `FilterArray` in `data/filter.zod.ts`). | diff --git a/packages/runtime/src/analytics-empty-cube-refusal.test.ts b/packages/runtime/src/analytics-empty-cube-refusal.test.ts new file mode 100644 index 00000000000..6155d96b037 --- /dev/null +++ b/packages/runtime/src/analytics-empty-cube-refusal.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `POST /analytics/query` and `POST /analytics/sql` refuse an EMPTY cube name + * at the door — `400 VALIDATION_FAILED`, a `fields[]` entry at `cube` — and + * the analytics service is never reached. + * + * The defect this pins shut: `AnalyticsQueryRequestSchema.cube` was a bare + * `z.string()`, so `{ cube: '' }` passed the door as "a string" and reached the + * engine, whose own guard threw a plain `Error`, which the dispatcher mapped + * to `500 INTERNAL_ERROR` — a client input mistake answered as a server fault, + * while the same body with `cube` left out answered 400. The accept set closes + * at the schema (`.min(1)`, one prescriptive sentence), so the assertions read + * the code, the status, the field entry and the "service not called" fact. + * + * Harness: the shape `analytics-daterange-refusal-envelope.test.ts` uses to + * drive the mounted route through `dispatcher-plugin`'s thrown-error exit. + */ + +import { describe, it, expect } from 'vitest'; +import { ApiErrorSchema, ANALYTICS_CUBE_NAME_REQUIRED } from '@objectstack/spec/api'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +// [#21061] The analytics domain refuses an anonymous caller first (ADR-0056 D2), +// so this harness signs its caller in; only identity is stubbed. +const SIGNED_IN_AUTH = { api: { getSession: async () => ({ user: { id: 'usr_analytics_caller' } }) } }; + +function makeFakeServer() { + const handlers: Record any> = {}; + const rec = (verb: string) => (path: string, handler: any) => { + handlers[`${verb} ${path}`] = handler; + }; + return { + handlers, + server: { get: rec('GET'), post: rec('POST'), put: rec('PUT'), delete: rec('DELETE'), patch: rec('PATCH') }, + }; +} + +function makeCtx(fakeServer: any, calls: { query: unknown[]; sql: unknown[] }) { + const analytics = { + query: async (body: unknown) => { calls.query.push(body); return { rows: [], fields: [] }; }, + getMeta: async () => [], + generateSql: async (body: unknown) => { calls.sql.push(body); return { sql: 'SELECT 1', params: [] }; }, + }; + const kernel = { + getService: (name: string) => (name === 'analytics' ? analytics : name === 'auth' ? SIGNED_IN_AUTH : undefined), + getServiceAsync: async (name: string) => (name === 'analytics' ? analytics : name === 'auth' ? SIGNED_IN_AUTH : undefined), + }; + return { + getKernel: () => kernel, + getService: (name: string) => (name === 'http.server' ? fakeServer : undefined), + environmentId: undefined, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + hook: () => {}, + on: () => {}, + } as any; +} + +function makeRes() { + const res: any = { + statusCode: undefined as number | undefined, + body: undefined as any, + status(c: number) { res.statusCode = c; return res; }, + header() { return res; }, + json(b: any) { res.body = b; return res; }, + }; + return res; +} + +type Door = '/analytics/query' | '/analytics/sql'; + +async function post(path: Door, body: unknown) { + const { server, handlers } = makeFakeServer(); + const calls = { query: [] as unknown[], sql: [] as unknown[] }; + const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); + await plugin.start?.(makeCtx(server, calls)); + const handler = handlers[`POST /api/v1${path}`]; + expect(handler, `POST /api/v1${path} must be mounted`).toBeTypeOf('function'); + const res = makeRes(); + await handler({ body, query: {} }, res); + return { res, calls }; +} + +const serviceCalls = (calls: { query: unknown[]; sql: unknown[] }) => [...calls.query, ...calls.sql]; + +describe.each(['/analytics/query', '/analytics/sql'])('%s — an empty cube name', (door) => { + it('answers 400 VALIDATION_FAILED with a `cube` field entry, and never calls the service', async () => { + const { res, calls } = await post(door, { cube: '', measures: ['x.count'] }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error.httpStatus).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_FAILED'); + // The per-field carrier the UI anchors on: the ADR-0114 catalog code for + // a too-short string, never Zod's `too_small`. + expect(res.body.error.details.fields).toEqual([ + { field: 'cube', code: 'min_length', message: ANALYTICS_CUBE_NAME_REQUIRED }, + ]); + // The message locates the field and carries the schema's prescription. + expect(res.body.error.message).toContain('cube: Name the cube to query'); + // The whole point: a client input mistake is no longer the engine's 500. + expect(serviceCalls(calls)).toEqual([]); + }); + + it('the envelope parses against ApiErrorSchema', async () => { + const { res } = await post(door, { cube: '', measures: ['x.count'] }); + const parsed = ApiErrorSchema.safeParse(res.body.error); + expect(parsed.success, JSON.stringify(parsed.success ? null : parsed.error.issues)).toBe(true); + }); + + it('a missing cube keeps the 400 VALIDATION_FAILED it already had, now with the `required` field code', async () => { + const { res, calls } = await post(door, { measures: ['x.count'] }); + + expect(res.statusCode).toBe(400); + expect(res.body.error.httpStatus).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_FAILED'); + expect(res.body.error.details.fields).toHaveLength(1); + // `required`, the ADR-0114 D3 code for an ABSENT key — the door hands + // the body to the mapper now; it used to answer Zod's `invalid_type` + // for the same body. The same code the engine's in-process guard + // answers (service-analytics `cube-name-refusal.ts`), so one condition + // reads one code on both sides; and the one prescription, carried by + // the member's type-level `error`, not Zod's generic words. + expect(res.body.error.details.fields[0]).toEqual({ field: 'cube', code: 'required', message: ANALYTICS_CUBE_NAME_REQUIRED }); + expect(res.body.error.message).toContain('cube: Name the cube to query'); + expect(serviceCalls(calls)).toEqual([]); + }); + + it('the `required` code reaches `measures` left out and a bodiless request too', async () => { + // The door hands the body to the mapper, so the D3 split reports a + // missing key as `required` (it used to read `invalid_type`): the two + // other cases the changeset names, pinned by field and code (their + // messages are Zod's own, no sentence of this change's). + const noMeasures = await post(door, { cube: 'orders' }); + expect(noMeasures.res.statusCode).toBe(400); + expect(noMeasures.res.body.error.code).toBe('VALIDATION_FAILED'); + expect(noMeasures.res.body.error.details.fields).toEqual([ + expect.objectContaining({ field: 'measures', code: 'required' }), + ]); + + const noBody = await post(door, undefined); + expect(noBody.res.statusCode).toBe(400); + expect(noBody.res.body.error.code).toBe('VALIDATION_FAILED'); + expect(noBody.res.body.error.details.fields).toEqual([ + expect.objectContaining({ field: '(body)', code: 'required' }), + ]); + expect(serviceCalls(noMeasures.calls)).toEqual([]); + expect(serviceCalls(noBody.calls)).toEqual([]); + }); + + it('preservation: a named cube reaches the service with the body untouched', async () => { + const body = { cube: 'orders', measures: ['orders.count'] }; + const { res, calls } = await post(door, body); + + expect(res.statusCode ?? 200).toBe(200); + expect(res.body.success).toBe(true); + expect(serviceCalls(calls)).toEqual([body]); + }); +}); diff --git a/packages/runtime/src/domains/analytics.ts b/packages/runtime/src/domains/analytics.ts index 03cf5d2b8b8..57bd7f6ac55 100644 --- a/packages/runtime/src/domains/analytics.ts +++ b/packages/runtime/src/domains/analytics.ts @@ -69,7 +69,12 @@ function assertAnalyticsQueryBody(body: unknown): void { } const parsed = AnalyticsQueryRequestSchema.safeParse(body); if (!parsed.success) { - const fields = fieldsFromZodIssues(parsed.error.issues); + // The body rides along so the ADR-0114 D3 split fires: an ABSENT + // required key (`cube` left out) is reported as `required`, not the + // `invalid_type` Zod spells it as — the code the engine's own guard + // answers for the same input in-process (`cube-name-refusal.ts`, + // service-analytics), so one condition reads one code on both sides. + const fields = fieldsFromZodIssues(parsed.error.issues, body); const message = `Invalid AnalyticsQuery body: ${fields.map((f) => `${f.field}: ${f.message}`).join('; ')}`; // [#16041] The closed-vocabulary refusal of `timeDimensions[].dateRange` // answers its own registered code, `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` diff --git a/packages/services/service-analytics/src/__tests__/analytics-service.test.ts b/packages/services/service-analytics/src/__tests__/analytics-service.test.ts index 189a92d2692..6df8df600c8 100644 --- a/packages/services/service-analytics/src/__tests__/analytics-service.test.ts +++ b/packages/services/service-analytics/src/__tests__/analytics-service.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { Cube } from '@objectstack/spec/data'; +import { ANALYTICS_CUBE_NAME_REQUIRED } from '@objectstack/spec/api'; import type { AnalyticsQuery, AnalyticsResult, AnalyticsStrategy, IAnalyticsService } from '@objectstack/spec/contracts'; import { AnalyticsService } from '../analytics-service.js'; import { CubeRegistry } from '../cube-registry.js'; @@ -478,9 +479,62 @@ describe('AnalyticsService', () => { await expect(service.query(baseQuery)).rejects.toThrow('No strategy can handle'); }); - it('should throw when cube name is missing', async () => { - const service = new AnalyticsService({ logger: silentLogger }); - await expect(service.query({ measures: ['count'] })).rejects.toThrow('Cube name is required'); + // A query that names no cube is refused with the typed `VALIDATION_FAILED` / + // 400 the HTTP doors answer for the same body — not a plain Error, which + // every boundary maps to a 500 (`cube-name-refusal.ts`). The message is the + // schema's own sentence (`ANALYTICS_CUBE_NAME_REQUIRED`); the `fields[]` + // entry is the door's answer for the same value, produced by the one D3 + // mapper. The pins read the envelope (`code` + `status`), the field entry + // and the sentence's head. + describe('a query that names no cube is the typed VALIDATION_FAILED refusal, in-process', () => { + const refused = { code: 'VALIDATION_FAILED', status: 400, name: 'ValidationError', message: ANALYTICS_CUBE_NAME_REQUIRED }; + const missing = { ...refused, fields: [{ field: 'cube', code: 'required', message: ANALYTICS_CUBE_NAME_REQUIRED }] }; + const empty = { ...refused, fields: [{ field: 'cube', code: 'min_length', message: ANALYTICS_CUBE_NAME_REQUIRED }] }; + + it('query(): a missing cube', async () => { + const service = new AnalyticsService({ logger: silentLogger }); + await expect(service.query({ measures: ['count'] })).rejects.toMatchObject(missing); + }); + + it('query(): an empty cube name', async () => { + const service = new AnalyticsService({ cubes: [ordersCube], logger: silentLogger }); + await expect(service.query({ cube: '', measures: ['count'] })).rejects.toMatchObject(empty); + }); + + it('generateSql(): a missing cube and an empty cube name', async () => { + const service = new AnalyticsService({ cubes: [ordersCube], logger: silentLogger }); + await expect(service.generateSql({ measures: ['count'] })).rejects.toMatchObject(missing); + await expect(service.generateSql({ cube: '', measures: ['count'] })).rejects.toMatchObject(empty); + }); + + it('a null cube from an untyped caller reads as the door reads it: invalid_type, not required', async () => { + const service = new AnalyticsService({ logger: silentLogger }); + await expect(service.query({ cube: null as unknown as string, measures: ['count'] })).rejects.toMatchObject({ + ...refused, + fields: [{ field: 'cube', code: 'invalid_type', message: ANALYTICS_CUBE_NAME_REQUIRED }], + }); + }); + + it('the refusal reads the prescription first', async () => { + const service = new AnalyticsService({ logger: silentLogger }); + await expect(service.query({ cube: '', measures: ['count'] })).rejects.toMatchObject({ + message: expect.stringMatching(/^Name the cube to query/), + }); + }); + + it('a whitespace-only name is a NAME, so it passes this guard: whatever refuses it later, it is not the empty-name refusal', async () => { + // The composed runtime answers it CUBE_NOT_FOUND / 404 from cube + // resolution; this harness has no object registry to resolve against, so + // only the guard's half is pinned here: the rule is minLength 1, no trim. + const service = new AnalyticsService({ cubes: [ordersCube], logger: silentLogger }); + const err: unknown = await service.query({ cube: ' ', measures: ['count'] }).then( + () => { throw new Error('expected a rejection'); }, + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(Error); + expect(err).not.toMatchObject({ code: 'VALIDATION_FAILED' }); + expect(err).not.toMatchObject({ message: expect.stringMatching(/^Name the cube to query/) }); + }); }); it('should return cube metadata via getMeta()', async () => { diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 07dcbe8c144..34d1d846868 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -34,6 +34,7 @@ import { import { matchMissingColumnOfRelation } from '@objectstack/types'; import { CubeRegistry } from './cube-registry.js'; import { cubeNotFoundError, isCubePublic } from './cube-visibility.js'; +import { cubeNameRequiredError } from './cube-name-refusal.js'; // The object-level read admission asked at this door, ahead of every strategy // — the layer the raw-SQL path could not inherit from the engine. See that // module's header for the request that reached the database without it. @@ -2412,8 +2413,12 @@ export class AnalyticsService implements IAnalyticsService { queryInput: AnalyticsQuery, context?: ExecutionContext, ): Promise { + // A query that names no cube — `cube` absent or the empty string — is + // refused before any other gate, as the typed `VALIDATION_FAILED` / 400 + // the HTTP doors answer for the same body; a plain Error here read as a + // 500 to the caller (`cube-name-refusal.ts`). if (!queryInput.cube) { - throw new Error('Cube name is required in analytics query'); + throw cubeNameRequiredError(queryInput.cube); } // `analytics_cube.public` — first, ahead of token resolution, cube // inference, admission and every strategy: a hidden cube is refused @@ -3358,8 +3363,9 @@ export class AnalyticsService implements IAnalyticsService { * Generate SQL for a query without executing it (dry-run). */ async generateSql(queryInput: AnalyticsQuery, context?: ExecutionContext): Promise<{ sql: string; params: unknown[] }> { + // Same refusal as `queryIn`: no cube named, no statement compiled. if (!queryInput.cube) { - throw new Error('Cube name is required for SQL generation'); + throw cubeNameRequiredError(queryInput.cube); } // Same gate as `query()`: the dry-run door must not hand out the // statement — the cube's measures, raw SQL included — of a hidden cube. diff --git a/packages/services/service-analytics/src/cube-name-refusal.ts b/packages/services/service-analytics/src/cube-name-refusal.ts new file mode 100644 index 00000000000..eb5a26e54c3 --- /dev/null +++ b/packages/services/service-analytics/src/cube-name-refusal.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ANALYTICS_CUBE_NAME_REQUIRED, AnalyticsQueryRequestSchema } from '@objectstack/spec/api'; +import { fieldsFromZodIssues, validationFailure, VALIDATION_FAILED_STATUS } from '@objectstack/types'; + +/** + * The ONE refusal for an analytics query that names no cube — `cube` absent, + * the empty string, or a `null` an untyped caller slipped past the guard — + * thrown by `AnalyticsService.query` (through `queryIn`) and + * `AnalyticsService.generateSql` before any other gate runs. + * + * ## Why a typed refusal, and why its words are the schema's + * + * The HTTP doors (`POST /analytics/query`, `POST /analytics/sql`) parse the body + * with `AnalyticsQueryRequestSchema`, whose `cube` is a string with a + * type-level `error` and a `.min(1)`, both carrying one sentence: a missing, + * non-string or empty name is refused there, `400 VALIDATION_FAILED` with a + * `fields[]` entry at `cube`, and the engine is never reached. The engine's + * own guard stays for the callers that do not pass through that schema — a + * plugin, a flow, a test, anything that holds the service — and the base + * `AnalyticsQuery` type leaves `cube` optional for them. Until this module the + * guard threw a plain `Error`, which every boundary maps to a 500: a client + * input mistake read as a server fault, paged operators and got retried. + * + * The refusal is `validationFailure` from `@objectstack/types` — the shape both + * dispatcher error exits and `resolveThrownHttpError` recognise as + * `VALIDATION_FAILED` — with `status: 400` declared on it as this service's + * sibling refusals do (`cubeNotFoundError`, 404), so an in-process caller reads + * `{ code, status }` without a boundary to decide the status for it. Its + * message is `ANALYTICS_CUBE_NAME_REQUIRED`, the sentence the schema refuses + * the empty string with: one rule, one wording, two readers. + * + * ## The `fields[]` entry is the door's, literally + * + * The value is run through the schema's own `cube` member and the issues + * through `fieldsFromZodIssues` with the value beside them — the one ADR-0114 + * D3 table in the repo (`zodIssuesToFields`, `@objectstack/spec`), the same + * call the door makes on the whole body. So the entry's code is whatever the + * door answers for the same value — `required` for an absent key, `min_length` + * for the empty string, `invalid_type` for a `null` an untyped caller slipped + * past `!queryInput.cube` — and its message is the member's own, which is the + * one prescription for every arm (the member carries a type-level `error` + * beside its `.min(1)`). ⛔ No + * hand-rolled `typeof` split here: a second copy of that table is the drift + * the ADR exists to prevent, and it disagreed with the door on `null` once. + * `analytics-empty-cube-refusal.test.ts` (runtime) pins the door's half, + * `analytics-service.test.ts` this one. + * + * ⛔ A whitespace-only name is NOT refused here: it is a name, one no cube + * carries, and the engine's cube resolution answers it `CUBE_NOT_FOUND` / 404. + * Refusing it would be a second rule beside the schema's `.min(1)`. + */ +export function cubeNameRequiredError(cube: unknown): Error & { code: string; status: number } { + // `lazySchema` caches the resolved request schema and `.shape` is itself + // cached, so this read costs nothing after the first refusal in a process. + const verdict = AnalyticsQueryRequestSchema.shape.cube.safeParse(cube); + if (verdict.success) { + // Both call sites guard with `!queryInput.cube`, and every falsy value + // fails the member; a caller reaching here with an accepted name would + // mint a refusal naming no field and prescribing a fix that is false. A + // programmer error, which a boundary rightly reports as a server fault. + throw new TypeError('cubeNameRequiredError: called with a cube name the schema accepts'); + } + const issues = verdict.error.issues.map((issue) => ({ ...issue, path: ['cube', ...issue.path] })); + const err = validationFailure(ANALYTICS_CUBE_NAME_REQUIRED, fieldsFromZodIssues(issues, { cube })) as Error & { + code: string; + status: number; + }; + err.status = VALIDATION_FAILED_STATUS; + return err; +} diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 81959d222f5..1b1751dbcd7 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -2,6 +2,7 @@ "description": "Every exported `name (kind)` of one published entry point of @objectstack/spec — the breadth half of the ADR-0059 backward-compatibility gate. Sharded by entry point (#5837) so two PRs touching different entry points never share a file. Reads the BUILT dist/*.d.ts: regenerate with `pnpm --filter @objectstack/spec gen:api-surface` after a real build.", "entry": "./api", "exports": [ + "ANALYTICS_CUBE_NAME_REQUIRED (const)", "AckMessage (type)", "AckMessageSchema (const)", "AiAgentCapabilities (type)", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 5397029f01a..bcf94723730 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -2,6 +2,7 @@ "description": "Which SOURCE DECLARATION each name exported by one public entry point of @objectstack/spec resolves to, after its alias chain is unwound: `# ()`. Two exports share an origin string iff they are the same declaration — so equal origins across two entries are a harmless re-export, and different origins under one name are the #4411 dual-source trap. Generated from src/ (no build needed) and read by the export-surface pin tests, which compare against it instead of each building their own ts.createProgram — that was ~55s of compilation per CI lap and a non-deterministic timeout that ejected unrelated PRs from the merge queue (#4796). Sharded by entry point (#5837) so two retirement PRs never share a file. Carries NO line numbers: the pins asserted the line as `\\d+`, and recording it would rewrite this artifact on every edit that shifts a line in any .zod.ts. Regenerate with `pnpm --filter @objectstack/spec gen:export-origins` and read the diff.", "entry": "./api", "exports": { + "ANALYTICS_CUBE_NAME_REQUIRED": "src/api/analytics.zod.ts#ANALYTICS_CUBE_NAME_REQUIRED (const)", "AckMessage": "src/api/websocket.zod.ts#AckMessage (type)", "AckMessageSchema": "src/api/websocket.zod.ts#AckMessageSchema (const)", "AiAgentCapabilities": "src/api/protocol.zod.ts#AiAgentCapabilities (type)", diff --git a/packages/spec/src/api/analytics.test.ts b/packages/spec/src/api/analytics.test.ts index 7fd492fe06e..cf919943de4 100644 --- a/packages/spec/src/api/analytics.test.ts +++ b/packages/spec/src/api/analytics.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; import { + ANALYTICS_CUBE_NAME_REQUIRED, AnalyticsEndpoint, AnalyticsQueryRequestSchema, AnalyticsResultResponseSchema, @@ -98,6 +100,51 @@ describe('AnalyticsQueryRequestSchema — the BARE AnalyticsQuery shape', () => ).toThrow(); }); + // An empty cube name used to pass this schema as "a string" and reach the + // engine, whose guard threw a plain Error — a 500 for a client input mistake, + // while a missing `cube` answered 400. The accept set closes at the schema: + // one issue, at `cube`, carrying the prescription the engine's own guard + // throws too (`ANALYTICS_CUBE_NAME_REQUIRED`, imported by service-analytics). + describe('an empty cube name is refused at the schema', () => { + it('refuses `cube: ""` with one too_small issue at `cube` reading the prescription', () => { + const parsed = AnalyticsQueryRequestSchema.safeParse({ cube: '', measures: ['x'] }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.issues).toHaveLength(1); + expect(parsed.error.issues[0].path).toEqual(['cube']); + expect(parsed.error.issues[0].code).toBe('too_small'); + expect(parsed.error.issues[0].message).toBe(ANALYTICS_CUBE_NAME_REQUIRED); + expect(ANALYTICS_CUBE_NAME_REQUIRED).toMatch(/^Name the cube to query/); + }); + + it('a missing cube keeps its own issue code (invalid_type at `cube`) and reads the same prescription', () => { + // The member carries a type-level `error` beside its `.min(1)`: the key + // left out, the most common client mistake, gets the one sentence too, + // while the issue code (and so the ADR-0114 field code, `required` once + // the mapper sees the input) still tells the cases apart. + const parsed = AnalyticsQueryRequestSchema.safeParse({ measures: ['x'] }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + const cubeIssues = parsed.error.issues.filter((issue) => issue.path[0] === 'cube'); + expect(cubeIssues).toHaveLength(1); + expect(cubeIssues[0].code).toBe('invalid_type'); + expect(cubeIssues[0].message).toBe(ANALYTICS_CUBE_NAME_REQUIRED); + }); + + it('a one-character name and a whitespace-only name still parse: the rule is minLength 1, nothing wider', () => { + expect(AnalyticsQueryRequestSchema.safeParse({ cube: 'o', measures: ['x'] }).success).toBe(true); + // Whitespace is a NAME, one no cube carries: the engine answers it + // CUBE_NOT_FOUND / 404, and this schema does not add a second rule. + expect(AnalyticsQueryRequestSchema.safeParse({ cube: ' ', measures: ['x'] }).success).toBe(true); + }); + + it('publishes the floor to JSON Schema as minLength 1 on `cube`', () => { + const json = z.toJSONSchema(AnalyticsQueryRequestSchema, { io: 'input' }) as any; + expect(json.properties.cube.minLength).toBe(1); + expect(json.required).toContain('cube'); + }); + }); + it('should reject missing measures', () => { expect(() => AnalyticsQueryRequestSchema.parse({ cube: 'test' }) diff --git a/packages/spec/src/api/analytics.zod.ts b/packages/spec/src/api/analytics.zod.ts index e1c9c6e1623..73d6676181c 100644 --- a/packages/spec/src/api/analytics.zod.ts +++ b/packages/spec/src/api/analytics.zod.ts @@ -30,6 +30,21 @@ export const AnalyticsEndpoint = z.enum([ // 2. Query Execution // ========================================== +/** + * The one sentence a `cube` that names no cube — absent, `null`, not a string, + * or the empty string — is refused with: by {@link AnalyticsQueryRequestSchema} + * at the HTTP doors (`POST /analytics/query`, `POST /analytics/sql`) and by the + * engine's in-process guard, which parses through the same member rather than + * wording a second rule. Exported so the two cannot drift. + * + * Declared ahead of the schema on purpose: under `OS_EAGER_SCHEMAS=1` + * (`gen:schema`) the factory runs at module evaluation, and a constant below + * it would still be in its temporal dead zone. + */ +export const ANALYTICS_CUBE_NAME_REQUIRED = + 'Name the cube to query: `cube` takes a cube name, as `GET /analytics/meta` lists them, or a ' + + 'registered object name; an empty string names no cube.'; + /** * Query Request Body — the BARE `AnalyticsQuery` shape (#3878). * @@ -47,10 +62,32 @@ export const AnalyticsEndpoint = z.enum([ * rejected now — `.strict()` — and the dispatcher's `/analytics` entry answers * 400 with a migration hint. The unimplemented `format` field went with it * (declared ≠ enforced: every response is the JSON envelope). + * + * `cube` is REQUIRED and NON-EMPTY here: the base `AnalyticsQuerySchema` leaves + * it optional for the in-process callers that resolve the cube themselves, but + * a request body names its cube or it names nothing the engine can serve. The + * empty string used to pass this schema as "a string" and reach the engine, + * whose own guard threw a plain Error — a client input mistake answered as a + * 500. The schema refuses it now with one prescriptive sentence, + * {@link ANALYTICS_CUBE_NAME_REQUIRED}, carried by every refusal of the slot + * — the type-level `error` for an absent, `null` or non-string `cube`, the + * `.min(1)` for the empty string — so the most common client mistake, the key + * left out, reads the prescription too and not Zod's generic words. The + * ADR-0114 field code still tells the cases apart (`required` / `invalid_type` + * / `min_length`). The engine's in-process guard (`AnalyticsService.query` / + * `generateSql`, `@objectstack/service-analytics`) runs a value through this + * same member and throws the same `VALIDATION_FAILED` refusal, so a caller + * inside the process and one at the HTTP door read the same answer. A + * whitespace-only name is a NAME, just one no cube carries, and keeps its + * `CUBE_NOT_FOUND` answer from the engine. */ export const AnalyticsQueryRequestSchema = lazySchema(() => AnalyticsQuerySchema.extend({ - cube: z.string().describe('Target cube name'), + cube: z.string({ error: ANALYTICS_CUBE_NAME_REQUIRED }).min(1, ANALYTICS_CUBE_NAME_REQUIRED).describe( + 'Target cube name — the cube to query, as `GET /analytics/meta` lists them, or a registered ' + + 'object name (read as an auto-inferred cube). Required and non-empty: an empty string names no ' + + 'cube and is refused at the schema.', + ), }).strict() ); diff --git a/packages/spec/src/migrations/entries/semantic/18.analytics-query-empty-cube-name-refused.ts b/packages/spec/src/migrations/entries/semantic/18.analytics-query-empty-cube-name-refused.ts new file mode 100644 index 00000000000..2b0364ae291 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.analytics-query-empty-cube-name-refused.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The request body of the two analytics query doors admitted an empty cube +// name as "a string" and let the engine answer it with a 500. No D2 +// conversion: nothing written can be rewritten into the cube the author meant. +export const entry: SemanticMigration = { + id: 'analytics-query-empty-cube-name-refused', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span already, and a nested backtick would close it. + surface: + 'AnalyticsQueryRequestSchema.cube (the body of POST /analytics/query and POST /analytics/sql), ' + + 'sent as the EMPTY string', + replacement: + 'name the cube to query (`cube: \'orders\'`): a cube `GET /analytics/meta` lists, or a registered ' + + 'object name, which is read as an auto-inferred cube. ⛔ There is no replacement that can be ' + + 'derived from what was written: an empty string names no cube, so the platform must not pick ' + + 'one for the caller.', + reason: + 'A request body names its cube or it names nothing the engine can serve. Measured against the ' + + 'published @objectstack/spec 18.2.0: POST /api/v1/analytics/query and POST /api/v1/analytics/sql ' + + 'with cube: "" passed the body schema (cube was z.string()) and reached the engine, whose own ' + + 'guard threw a plain Error, which the door mapped to 500 INTERNAL_ERROR — a client input mistake ' + + 'that read as a server fault, paged operators and got retried, while the same body with cube left ' + + 'out answered 400. The schema now refuses the empty string (minLength 1) with one prescriptive ' + + 'sentence ("Name the cube to query …"), 400 VALIDATION_FAILED with a fields[] entry at cube, and ' + + 'the engine is never reached. In the same release the engine\'s in-process guard ' + + '(AnalyticsService.query and generateSql in @objectstack/service-analytics) throws the same typed ' + + 'refusal — code VALIDATION_FAILED, status 400, the schema\'s own sentence — for an absent or ' + + 'empty cube, so a plugin, flow or test that holds the service reads the same answer the door ' + + 'gives. Nothing is stored: a cube name travels in a request, never in stack metadata, so there is ' + + 'no row to rewrite and no D2 conversion — one semantic TODO for API callers. A whitespace-only ' + + 'name is unchanged: it is a name no cube carries and keeps its 404 CUBE_NOT_FOUND. ADR-0087.', + acceptanceCriteria: + 'Search every caller of POST /analytics/query and POST /analytics/sql (dashboard widgets, ' + + 'client.analytics.query / generateSql, scripts) and every in-process AnalyticsService.query / ' + + 'generateSql call for a cube built from a value that can be empty — an unset selection, a blank ' + + 'form field, a missing config key. Name the cube, or refuse to send the query until one is ' + + 'chosen. The sweep is mechanical at the door: AnalyticsQueryRequestSchema.safeParse answers one ' + + 'issue at the cube path reading "Name the cube to query", and the HTTP doors answer 400 ' + + 'VALIDATION_FAILED with fields[0].field === "cube" and the engine not called. A non-empty cube ' + + 'parses unchanged, and a missing cube keeps the 400 it already had. The repository carried no ' + + 'caller sending an empty cube at the time of the change.', +};