diff --git a/.changeset/20282-analytics-cube-public-enforced.md b/.changeset/20282-analytics-cube-public-enforced.md new file mode 100644 index 00000000000..323861e4f64 --- /dev/null +++ b/.changeset/20282-analytics-cube-public-enforced.md @@ -0,0 +1,28 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-analytics': minor +--- + +An analytics cube's `public` now takes effect, and it defaults to visible: `CubeSchema.public` defaults to `true` (it was `false`), and the analytics service hides a cube that declares `public: false` from discovery and refuses every query against it (#20282). + +Clause-②: yes (narrowing) + + + +**BREAKING**: this narrows what the analytics API answers. A query or SQL dry run against a cube declared `public: false` (`POST /api/v1/analytics/query`, `POST /api/v1/analytics/sql`) was answered before this change and is now refused with `404 CUBE_NOT_FOUND`, and `GET /api/v1/analytics/meta` no longer lists that cube. The same happens to every cube in an artifact built by `os compile` before this release, which carries a materialized `public: false` from the old default. The remedy: delete `public: false` from any cube that is meant to be queried (cubes are visible by default), and recompile pre-release artifacts. It ships as `minor` under the launch-window convention; the widening half is the default moving to visible. + +Until this change nothing read `public`. `GET /api/v1/analytics/meta` listed a `public: false` cube and every query door answered it, so the flag withheld nothing. Its declared default, `false`, could not simply be switched on: enforcing it as declared would have hidden every cube that omits the key. The default is now the Cube.dev default (visible), and an explicit `false` is enforced: + +- `GET /api/v1/analytics/meta` omits a cube declared `public: false`, and `?cube=` naming one answers `[]`, the same as a name no cube has. +- `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql` refuse it with `404 CUBE_NOT_FOUND` — the same refusal, byte for byte, that an unknown cube name gets, so a caller cannot use it to learn that a hidden cube exists. The one shared message names both possibilities, so it still tells an author how to expose a hidden cube. The refusal comes before any SQL is built, and it is never an empty result. + +`public` is visibility on the analytics API, not row security. An object's records stay governed by its permissions and row-level security on every door, whether or not a cube over it is hidden. What `public: false` does is exactly the two points above: the cube is left out of `/analytics/meta`, and queries and SQL generation against it are refused. The cube's definition stays readable on the metadata door, like any other authored schema. + +What to expect after upgrading: + +- **A cube that omits `public`** stays visible and queryable. It was visible before too, because nothing read the key. A client that parses cube metadata through the published JSON Schema now materializes `public: true` where it materialized `false`. +- **A cube that writes `public: false`** is now hidden and refused. If you wrote it only because it was the old default, delete the line (cubes are visible by default). A dashboard or report that queries such a cube starts answering `404 CUBE_NOT_FOUND` until you do. +- **A compiled artifact built before this release** carries a materialized `public: false` on every cube, because `os compile` writes the parsed stack with its defaults applied. Recompile it with this release before serving cubes from it. +- **Cubes the platform mints itself** stay visible: the cube inferred for an ad-hoc query on an object (the KPI path), a compiled dataset's cube (`POST /api/v1/analytics/dataset/query`), and `CubeRegistry.inferFromObject`. Each wrote a literal `false`, the old default, and now writes `true`. + +The showcase example's `showcase_delivery` cube, which is the app's demonstration of `/api/v1/analytics/*`, drops its `public: false`. diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index 0ffc010ee18..9ce59d1e6b4 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -318,7 +318,7 @@ const result = await client.analytics.query({ }); console.log(result.rows.length, result.fields[0].name); // AnalyticsResult, unwrapped -// Get cube metadata — all cubes, or one with meta('account') +// Get cube metadata — all cubes (those declared public: false are omitted), or one with meta('account') const meta = await client.analytics.meta('account'); console.log(meta[0].name, meta[0].measures.length); // the bare cube list diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx index 73c13525167..6c274210c41 100644 --- a/content/docs/api/data-api.mdx +++ b/content/docs/api/data-api.mdx @@ -479,10 +479,12 @@ so its column shows the tenant default. The cube query on this page carries no c ### `GET /analytics/meta` -Get metadata for all registered cubes. Cubes are explicitly defined (via `defineCube` -or the analytics service's `cubes` config) — a cube referenced by a query that isn't -yet registered is lazily auto-inferred from that query's shape, but metadata isn't -proactively generated for every object. +Get metadata for all registered cubes, except those declared `public: false`, which are +omitted (and refused by `POST /analytics/query` and `POST /analytics/sql` with the same +`404 CUBE_NOT_FOUND` an unknown cube name gets). Cubes are explicitly defined (via +`defineCube` or the analytics service's `cubes` config) — a cube referenced by a query +that isn't yet registered is lazily auto-inferred from that query's shape, but metadata +isn't proactively generated for every object. Pass `?cube=` to filter the listing to a single cube (this is what `client.analytics.meta(cube)` sends). diff --git a/content/docs/references/data/analytics.mdx b/content/docs/references/data/analytics.mdx index a63e902cd29..1c6f2390fef 100644 --- a/content/docs/references/data/analytics.mdx +++ b/content/docs/references/data/analytics.mdx @@ -129,7 +129,7 @@ Type: `[string, string]` | **dimensions** | `Record; … }>` | ✅ | Qualitative attributes | | **joins** | `Record` | optional | | | **refreshKey** | `{ every?: string; sql?: string }` | optional | | -| **public** | `boolean` | optional (default: `false`) | | +| **public** | `boolean` | optional (default: `true`) | Whether the analytics API exposes this cube. Default true (visible). false hides it from GET /analytics/meta and refuses POST /analytics/query and /analytics/sql for it (CUBE_NOT_FOUND). Visibility only: the underlying object's permissions and row-level security still govern its records on every door. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | diff --git a/docs/qa/platform-checklist/areas/dashboards.json b/docs/qa/platform-checklist/areas/dashboards.json index be4d647630b..b6bd50049fe 100644 --- a/docs/qa/platform-checklist/areas/dashboards.json +++ b/docs/qa/platform-checklist/areas/dashboards.json @@ -964,7 +964,7 @@ "title": "The showcase_delivery analytics cube serves /api/v1/analytics/*: meta discovers its measures/dimensions, a query answers a known aggregate that reconciles against a direct /data aggregate, and an unwired analytics slot degrades honestly to 404", "since": "v16", "status": "active", - "revision": 1, + "revision": 2, "priority": "P2", "surface": "api", "personas": [ @@ -990,7 +990,7 @@ { "clause": "meta discovers the cube: GET /analytics/meta lists showcase_delivery with exactly its four measures (namespaced showcase_delivery.count / .total_estimate_hours / .avg_estimate_hours / .done_rate) and four dimensions (showcase_delivery.status / .priority / .due_date / .assignee)", "oracle": "api", - "verify": "the meta response's cubes[] entry for showcase_delivery names all four measures and four dimensions (getMeta keys them `${cube}.${key}` and does NOT filter on the cube's public:false flag)", + "verify": "the meta response's cubes[] entry for showcase_delivery names all four measures and four dimensions (getMeta keys them `${cube}.${key}`, and lists a cube only when its `public` is not `false`: a cube declaring `public: false` is omitted from meta and refused by query()/generateSql() with 404 CUBE_NOT_FOUND. showcase_delivery declares no `public`, so it is visible by the schema default `true`)", "evidence": "the /analytics/meta?cube=showcase_delivery body" }, { @@ -1042,11 +1042,11 @@ "single-datapoint" ], "source": [ - "examples/app-showcase/src/data/analytics/showcase.cube.ts#showcase_project (the showcase_delivery cube — measures, dimensions, base table, showcase_project join, public:false)", + "examples/app-showcase/src/data/analytics/showcase.cube.ts#showcase_project (the showcase_delivery cube — measures, dimensions, base table, showcase_project join; no `public` key, so visible by the default `true`)", "examples/app-showcase/src/coverage.ts#analyticsCubes (analyticsCubes registration → src/data/analytics/showcase.cube.ts, served by /api/v1/analytics/*)", "packages/runtime/src/domains/analytics.ts#cube (route contract: POST /analytics/query, GET /analytics/meta[?cube], entry validation, ExecutionContext scoping #2852, handled:false 404 for an absent/stub slot #3891/#4000)", "packages/spec/src/api/analytics.zod.ts#AnalyticsQueryRequestSchema (AnalyticsQueryRequestSchema bare shape + retiredKey query/format; meta response cubes[])", - "packages/services/service-analytics/src/analytics-service.ts#getMeta (getMeta keys measures/dimensions as `${cube}.${key}`, returns all registry cubes)", + "packages/services/service-analytics/src/analytics-service.ts#getMeta (getMeta keys measures/dimensions as `${cube}.${key}` and omits a registry cube whose `public` is `false` (cube-visibility.ts#isCubePublic); query()/generateSql() refuse such a cube with 404 CUBE_NOT_FOUND through assertCubePublic)", "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS (CAPABILITY_PROVIDERS.analytics → @objectstack/service-analytics, configKey analyticsCubes)" ], "history": [ @@ -1055,6 +1055,12 @@ "date": "2026-08-08", "change": "new item: showcase_delivery cube /analytics/* meta+query reconciliation against the direct /data aggregate, with the honest empty-slot 404 degradation clause and the entry-validation negatives", "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-09-28", + "change": "re-spelled the meta clause's verify text and two source lines, which stopped being true when the analytics service began reading `analytics_cube.public`: getMeta no longer returns every registry cube (it omits one declaring `public: false`, and query()/generateSql() refuse it with 404 CUBE_NOT_FOUND), and the showcase cube no longer declares `public: false` (it is visible by the default `true`). Text only: every clause, step and verdict is unchanged, because showcase_delivery stays visible and its meta entry still names the same four measures and four dimensions", + "ref": "#20348" } ] }, diff --git a/examples/app-showcase/src/data/analytics/showcase.cube.ts b/examples/app-showcase/src/data/analytics/showcase.cube.ts index dfab7d71b4f..fe8bb4cca28 100644 --- a/examples/app-showcase/src/data/analytics/showcase.cube.ts +++ b/examples/app-showcase/src/data/analytics/showcase.cube.ts @@ -95,7 +95,12 @@ export const DeliveryCube = defineCube({ refreshKey: { every: '1 hour', }, - public: false, + // No `public` key: the cube is VISIBLE, the default. It is this app's + // demonstration of the `/api/v1/analytics/*` surface (src/coverage.ts marks + // `analyticsCubes` demonstrated, and the platform checklist's dashboards item + // discovers and queries it there). `public: false` would hide it from + // `/analytics/meta` and refuse every query against it — this file authored + // exactly that, inertly, until the analytics service began reading the key. }); export const allCubes = [DeliveryCube]; diff --git a/examples/app-showcase/test/gap-fill.test.ts b/examples/app-showcase/test/gap-fill.test.ts index 947758d7a6b..749a0b0dce2 100644 --- a/examples/app-showcase/test/gap-fill.test.ts +++ b/examples/app-showcase/test/gap-fill.test.ts @@ -36,6 +36,15 @@ describe('showcase gap fill — analytics cube', () => { expect(DeliveryCube.joins?.project?.name).toBe('showcase_project'); }); + it('is VISIBLE on the analytics API — it demonstrates /api/v1/analytics/*, so it cannot be hidden', () => { + // `public: false` hides a cube from `/analytics/meta` and refuses every + // query against it (service-analytics `cube-visibility.ts`). This cube is + // the showcase's `analyticsCubes` demonstration (src/coverage.ts), so a + // hidden one would demonstrate a 404. `defineCube` parses, so the omitted + // key reads back as the schema default. + expect(DeliveryCube.public).toBe(true); + }); + it('keys every join by a FOREIGN-KEY FIELD of its own base object, not by the target', () => { // #18612: the `joins` record KEY is what both strategies join ON — native // emits `ON ""."" = ""."id"`, ObjectQL lowers `fkField: key` diff --git a/packages/runtime/src/cross-field-refusal-operand-withhold.test.ts b/packages/runtime/src/cross-field-refusal-operand-withhold.test.ts index 8d058add690..9d8bbc07580 100644 --- a/packages/runtime/src/cross-field-refusal-operand-withhold.test.ts +++ b/packages/runtime/src/cross-field-refusal-operand-withhold.test.ts @@ -128,7 +128,7 @@ const CUBE: Cube = { (n) => [n, { name: n, label: n, type: 'string', sql: n }], ), ), - public: false, + public: true, } as unknown as Cube; interface WireBearingError extends Error { diff --git a/packages/services/service-analytics/src/__tests__/aggregate-bridge-function-vocabulary.test.ts b/packages/services/service-analytics/src/__tests__/aggregate-bridge-function-vocabulary.test.ts index 48d3e4bb0a9..8e1cb40dd24 100644 --- a/packages/services/service-analytics/src/__tests__/aggregate-bridge-function-vocabulary.test.ts +++ b/packages/services/service-analytics/src/__tests__/aggregate-bridge-function-vocabulary.test.ts @@ -84,7 +84,7 @@ const cubeWithMeasureType = (type: string): Cube => ({ sql: 'opportunity', measures: { revenue: { name: 'revenue', label: 'Revenue', type, sql: 'amount' } as Cube['measures'][string] }, dimensions: { region: { name: 'region', label: 'Region', type: 'string', sql: 'region' } }, - public: false, + public: true, }); async function analyticsVia(engine: unknown, cube: Cube): Promise { 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 b1e1af241b0..1ee6c8df071 100644 --- a/packages/services/service-analytics/src/__tests__/analytics-service.test.ts +++ b/packages/services/service-analytics/src/__tests__/analytics-service.test.ts @@ -31,7 +31,7 @@ const ordersCube: Cube = { granularities: ['day', 'week', 'month'], }, }, - public: false, + public: true, }; const baseQuery: AnalyticsQuery = { diff --git a/packages/services/service-analytics/src/__tests__/cross-field-engine-fallback.test.ts b/packages/services/service-analytics/src/__tests__/cross-field-engine-fallback.test.ts index 567917bdd41..5b6efb53e95 100644 --- a/packages/services/service-analytics/src/__tests__/cross-field-engine-fallback.test.ts +++ b/packages/services/service-analytics/src/__tests__/cross-field-engine-fallback.test.ts @@ -85,7 +85,7 @@ const CUBE: Cube = { (n) => [n, { name: n, label: n, type: 'string', sql: n }], ), ), - public: false, + public: true, } as unknown as Cube; interface WireBearingError extends Error { diff --git a/packages/services/service-analytics/src/__tests__/cross-field-offset-dataset.test.ts b/packages/services/service-analytics/src/__tests__/cross-field-offset-dataset.test.ts index b3d779a15aa..912ddb5e74f 100644 --- a/packages/services/service-analytics/src/__tests__/cross-field-offset-dataset.test.ts +++ b/packages/services/service-analytics/src/__tests__/cross-field-offset-dataset.test.ts @@ -126,7 +126,7 @@ const CUBE: Cube = { dimensions: Object.fromEntries( Object.keys(CROSS_FIELD_OFFSET_OBJECT_FIELDS).map((n) => [n, { name: n, label: n, type: 'string', sql: n }]), ), - public: false, + public: true, } as unknown as Cube; /** The ruling's dataset: the on-time count, the late count, and the rate. */ diff --git a/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts b/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts index 2276d3f2de3..24d019ff678 100644 --- a/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts @@ -156,7 +156,7 @@ const CUBE: Cube = { amount: { name: 'amount', label: 'Amount', type: 'number', sql: 'amount' }, budget: { name: 'budget', label: 'Budget', type: 'number', sql: 'budget' }, }, - public: false, + public: true, } as unknown as Cube; // ── The supported arm: routed, not refused ─────────────────────────────────── diff --git a/packages/services/service-analytics/src/__tests__/cube-inference-gate.test.ts b/packages/services/service-analytics/src/__tests__/cube-inference-gate.test.ts index 354a34b3bb4..0cab0adbe4e 100644 --- a/packages/services/service-analytics/src/__tests__/cube-inference-gate.test.ts +++ b/packages/services/service-analytics/src/__tests__/cube-inference-gate.test.ts @@ -40,7 +40,7 @@ const authoredCube: Cube = { sql: 'some_physical_table', measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, dimensions: {}, - public: false, + public: true, }; /** Records which object each aggregate ran against, so we can assert none ran. */ diff --git a/packages/services/service-analytics/src/__tests__/cube-public-visibility.test.ts b/packages/services/service-analytics/src/__tests__/cube-public-visibility.test.ts new file mode 100644 index 00000000000..19009a1a94b --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/cube-public-visibility.test.ts @@ -0,0 +1,275 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `analytics_cube.public` — the analytics API honours an authored cube's + * visibility (the Cube.dev semantics this schema follows: a cube declared + * `public: false` is neither discovered nor queryable through the API). + * + * Until this change the key was parsed, stored and read by NOTHING: `getMeta` + * listed a `public: false` cube and every query door answered it, so an author + * who wrote the flag got a cube exactly as exposed as one that did not — an + * access-shaped key that gated nothing. The default was `false` too, which is + * why it could never simply be switched on: enforcing it as declared would have + * hidden every authored cube. The default is now the mainstream "visible", and + * an explicit `false` hides. + * + * What this file pins, one door at a time: + * + * - discovery: `getMeta()` omits a hidden cube, and `getMeta(name)` answers a + * hidden name with nothing — the same answer as a name no cube has; + * - the query doors: `query()` (`POST /analytics/query`) and `generateSql()` + * (`POST /analytics/sql`) refuse a hidden cube with the declared + * `CUBE_NOT_FOUND` / 404 envelope — never an empty result — before a strategy + * runs and before the registry is touched; + * - non-disclosure: that refusal is byte-identical (status, code, message) to + * the one a name no cube and no object carries gets, so it does not confirm + * that a hidden cube exists; + * - the controls: a cube that declares `public: true`, and one that omits the + * key (input shape, and parsed through `CubeSchema` the way `defineCube` does), + * are listed and answered; + * - the three INTERNAL producers (`inferCubeFromQuery`, `compileDataset`, + * `CubeRegistry.inferFromObject`) mint visible cubes, so the ad-hoc KPI path + * and the dataset door — whose `DatasetExecutor` queries run through the + * same gate, asked of the call's own request scope — keep answering after + * the flag became enforced, and a dataset named like a hidden cube runs as + * itself rather than answering in a way that would reveal the hidden name. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { CubeSchema, type Cube } from '@objectstack/spec/data'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import { AnalyticsService } from '../analytics-service.js'; +import { CubeRegistry } from '../cube-registry.js'; +import { compileDataset } from '../dataset-compiler.js'; + +const silentLogger = { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn().mockReturnThis(), +} as any; + +const measures = { count: { name: 'count', label: 'Count', type: 'count' as const, sql: '*' } }; +const dimensions = { status: { name: 'status', label: 'Status', type: 'string' as const, sql: 'status' } }; + +const hiddenCube: Cube = { name: 'hidden_cube', sql: 'hidden_table', measures, dimensions, public: false }; +const visibleCube: Cube = { name: 'visible_cube', sql: 'visible_table', measures, dimensions, public: true }; +/** Input shape with the key OMITTED — `register` never parses, so this is what an unparsed caller hands in. */ +const omittedCube: Cube = { name: 'omitted_cube', sql: 'omitted_table', measures, dimensions }; +/** Parsed the way `defineCube()` and `defineStack({ analyticsCubes })` parse an authored cube. */ +const parsedCube = CubeSchema.parse({ name: 'parsed_cube', sql: 'parsed_table', measures, dimensions }); + +/** Records every object an aggregate ran against, so a refusal can prove no strategy ran. */ +function makeService(cubes: Cube[] = [hiddenCube, visibleCube, omittedCube, parsedCube]) { + const aggregated: string[] = []; + const service = new AnalyticsService({ + logger: silentLogger, + cubes, + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (objectName: string) => { + aggregated.push(objectName); + return [{ count: 1 }]; + }, + isRegisteredObject: (n: string) => ['crm_account', 'opportunity'].includes(n), + }); + return { service, aggregated }; +} + +const HIDDEN_REFUSAL = { code: 'CUBE_NOT_FOUND', status: 404, cube: 'hidden_cube' }; + +describe('analytics_cube.public — discovery (`getMeta`)', () => { + it('omits a cube declared `public: false` and lists every visible one', async () => { + const { service } = makeService(); + + const names = (await service.getMeta()).map((c) => c.name); + + expect(names).not.toContain('hidden_cube'); + expect(names).toEqual(['visible_cube', 'omitted_cube', 'parsed_cube']); + }); + + it('answers a hidden name with nothing — the same answer as a name no cube has', async () => { + const { service } = makeService(); + + expect(await service.getMeta('hidden_cube')).toEqual([]); + expect(await service.getMeta('no_such_cube')).toEqual([]); + expect((await service.getMeta('visible_cube')).map((c) => c.name)).toEqual(['visible_cube']); + }); +}); + +describe('analytics_cube.public — every query door refuses a hidden cube', () => { + it('`query()` refuses with the declared CUBE_NOT_FOUND/404 envelope, and no strategy runs', async () => { + const { service, aggregated } = makeService(); + + await expect( + service.query({ cube: 'hidden_cube', measures: ['hidden_cube.count'] }), + ).rejects.toMatchObject(HIDDEN_REFUSAL); + + expect(aggregated).toEqual([]); + }); + + it('`generateSql()` refuses the same way — the dry-run door shows no statement for a hidden cube', async () => { + const { service, aggregated } = makeService(); + + await expect( + service.generateSql({ cube: 'hidden_cube', measures: ['hidden_cube.count'] }), + ).rejects.toMatchObject(HIDDEN_REFUSAL); + + expect(aggregated).toEqual([]); + }); + + it('refuses before the registry is touched — a suffix-inferred measure does not augment the hidden cube', async () => { + const { service } = makeService(); + const before = service.cubeRegistry.get('hidden_cube'); + + await expect( + service.query({ cube: 'hidden_cube', measures: ['amount_sum'] }), + ).rejects.toMatchObject(HIDDEN_REFUSAL); + + expect(service.cubeRegistry.get('hidden_cube')).toBe(before); + }); + + it('a refusal is not an empty result: the promise rejects rather than resolving with no rows', async () => { + const { service } = makeService(); + + const outcome = await service.query({ cube: 'hidden_cube', measures: ['hidden_cube.count'] }).then( + (result) => ({ resolved: result }), + (error: unknown) => ({ rejected: error }), + ); + + expect(outcome).not.toHaveProperty('resolved'); + expect(outcome).toHaveProperty('rejected'); + }); +}); + +describe('analytics_cube.public — a hidden cube is indistinguishable from a missing one', () => { + type Refusal = Error & { code?: string; status?: number; cube?: string }; + const refusalOf = (run: () => Promise): Promise => + run().then(() => undefined, (e: unknown) => e as Refusal); + const envelope = (e: Refusal | undefined) => ({ + status: e?.status, + code: e?.code, + message: e?.message, + cube: e?.cube, + keys: Object.keys(e ?? {}).sort(), + }); + + it.each(['query', 'generateSql'] as const)( + '`%s()`: the same name, hidden in one deployment and absent from another, answers an equal status, code and message', + async (door) => { + const secret: Cube = { name: 'secret_cube', sql: 'secret_table', measures, dimensions, public: false }; + const hidden = makeService([secret]).service; // registered, declared public: false + const absent = makeService([]).service; // no such cube, and not a registered object + const q = { cube: 'secret_cube', measures: ['secret_cube.count'] }; + + const whenHidden = await refusalOf(() => hidden[door](q)); + const whenAbsent = await refusalOf(() => absent[door](q)); + + expect(whenHidden).toBeInstanceOf(Error); + expect(whenAbsent).toBeInstanceOf(Error); + expect(whenHidden?.status).toBe(404); + expect(whenHidden?.code).toBe('CUBE_NOT_FOUND'); + // Byte for byte: a caller who guesses a name learns nothing about whether + // a hidden cube stands behind it. + expect(envelope(whenHidden)).toEqual(envelope(whenAbsent)); + }, + ); +}); + +describe('analytics_cube.public — the controls stay open', () => { + it.each(['visible_cube', 'omitted_cube', 'parsed_cube'])('`%s` is answered by `query()`', async (name) => { + const { service, aggregated } = makeService(); + + await service.query({ cube: name, measures: [`${name}.count`] }); + + expect(aggregated).toEqual([name.replace('_cube', '_table')]); + }); + + it('an authored cube that omits `public` parses to visible (the spec default feeds the runtime)', () => { + expect(parsedCube.public).toBe(true); + }); +}); + +describe('analytics_cube.public — the internal producers mint visible cubes', () => { + it('the ad-hoc KPI path: an inferred cube is answered again on the next request, and listed', async () => { + const { service, aggregated } = makeService([]); + + await service.query({ cube: 'crm_account', measures: ['count'] }); + // The first request REGISTERED the inferred cube; the second resolves it + // from the registry, which is where a hidden verdict would now refuse it. + await service.query({ cube: 'crm_account', measures: ['count'] }); + + expect(aggregated).toEqual(['crm_account', 'crm_account']); + expect(service.cubeRegistry.get('crm_account')?.public).toBe(true); + expect((await service.getMeta()).map((c) => c.name)).toEqual(['crm_account']); + }); + + it('the dataset door: `queryDataset` runs its compiled cube through the same gate, and it answers', async () => { + const service = new AnalyticsService({ + logger: silentLogger, + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async () => [{ stage: 'won', total: 3 }], + }); + const dataset = DatasetSchema.parse({ + name: 'pipeline', + label: 'Pipeline', + object: 'opportunity', + dimensions: [{ name: 'stage', field: 'stage', type: 'string' }], + measures: [{ name: 'total', aggregate: 'count' }], + }); + + const result = await service.queryDataset(dataset, { dimensions: ['stage'], measures: ['total'] }); + + expect(result.rows).toEqual([{ stage: 'won', total: 3 }]); + }); + + it('the dataset door does not reveal a hidden name: a dataset named like a hidden cube runs as itself, exactly like any other name', async () => { + const secret: Cube = { name: 'pipeline', sql: 'secret_table', measures, dimensions, public: false }; + const withHidden = new AnalyticsService({ + logger: silentLogger, + cubes: [secret], + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async () => [{ stage: 'won', total: 3 }], + }); + const without = new AnalyticsService({ + logger: silentLogger, + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async () => [{ stage: 'won', total: 3 }], + }); + const dataset = DatasetSchema.parse({ + name: 'pipeline', + label: 'Pipeline', + object: 'opportunity', + dimensions: [{ name: 'stage', field: 'stage', type: 'string' }], + measures: [{ name: 'total', aggregate: 'count' }], + }); + const selection = { dimensions: ['stage'], measures: ['total'] }; + + // The call's own compiled cube answers its name inside the request, so the + // gate asks about THAT cube (visible) — the same outcome as for a name no + // configured cube has, which is what keeps this door from being an oracle. + expect((await withHidden.queryDataset(dataset, selection)).rows).toEqual( + (await without.queryDataset(dataset, selection)).rows, + ); + // …and the configured hidden cube is untouched: still omitted, still refused. + expect(await withHidden.getMeta('pipeline')).toEqual([]); + await expect(withHidden.query({ cube: 'pipeline', measures: ['pipeline.count'] })).rejects.toMatchObject({ + code: 'CUBE_NOT_FOUND', + status: 404, + }); + }); + + it('`compileDataset` and `CubeRegistry.inferFromObject` write the visible default', () => { + const compiled = compileDataset(DatasetSchema.parse({ + name: 'pipeline', + label: 'Pipeline', + object: 'opportunity', + dimensions: [{ name: 'stage', field: 'stage', type: 'string' }], + measures: [{ name: 'total', aggregate: 'count' }], + })); + const inferred = new CubeRegistry().inferFromObject('tasks', [{ name: 'title', type: 'text', label: 'Title' }]); + + expect(compiled.cube.public).toBe(true); + expect(inferred.public).toBe(true); + }); +}); diff --git a/packages/services/service-analytics/src/__tests__/dataset-refusal-envelope.test.ts b/packages/services/service-analytics/src/__tests__/dataset-refusal-envelope.test.ts index 13e0786177e..6a0f8d0d6fb 100644 --- a/packages/services/service-analytics/src/__tests__/dataset-refusal-envelope.test.ts +++ b/packages/services/service-analytics/src/__tests__/dataset-refusal-envelope.test.ts @@ -113,7 +113,7 @@ const bareCube: Cube = { sql: 'crm_opportunity', measures: { revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' } }, dimensions: { stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' } }, - public: false, + public: true, }; function nativeCtx(allowed: Set): StrategyContext { diff --git a/packages/services/service-analytics/src/__tests__/dimension-source-field-gate.test.ts b/packages/services/service-analytics/src/__tests__/dimension-source-field-gate.test.ts index 8e08c91d780..fd0516c99d4 100644 --- a/packages/services/service-analytics/src/__tests__/dimension-source-field-gate.test.ts +++ b/packages/services/service-analytics/src/__tests__/dimension-source-field-gate.test.ts @@ -281,7 +281,7 @@ describe('#5520 — the gate: a dimension over a missing field is a 400, not a d industry: { name: 'industry', label: 'Industry', type: 'string', sql: 'industry' }, legacy: { name: 'legacy', label: 'Legacy', type: 'string', sql: 'dropped_column' }, }, - public: false, + public: true, }; const { service, aggregated } = makeService({ cubes: [authored] }); @@ -350,7 +350,7 @@ describe('#5520 — the dataset face: refused before SQL exists, so nothing can dimensions: { bogus_dim: { name: 'bogus_dim', label: 'x', type: 'string', sql: 'bogus_dim' }, }, - public: false, + public: true, }; const { service } = makeService({ cubes: [derived], native: true }); @@ -425,7 +425,7 @@ describe('#5520 — what the gate must NOT do', () => { dimensions: { assessed: { name: 'assessed', label: 'Assessed', type: 'time', sql: 'assessed_at' }, }, - public: false, + public: true, }; const { service, aggregated } = makeService({ cubes: [authored] }); @@ -457,7 +457,7 @@ describe('#5520 — what the gate must NOT do', () => { dimensions: { anything: { name: 'anything', label: 'x', type: 'string', sql: 'anything' }, }, - public: false, + public: true, }; const { service } = makeService({ cubes: [derived] }); @@ -478,7 +478,7 @@ describe('#5520 — what the gate must NOT do', () => { sql: 'crm_account', measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, dimensions: {}, - public: false, + public: true, }; const { service } = makeService({ cubes: [joined] }); @@ -504,7 +504,7 @@ describe('#5520 — what the gate must NOT do', () => { sql: "CASE WHEN annual_revenue > 0 THEN 'yes' ELSE 'no' END", }, }, - public: false, + public: true, }; const { service } = makeService({ cubes: [computed] }); @@ -536,7 +536,7 @@ describe('#5520 — what the gate must NOT do', () => { dimensions: { ghost: { name: 'ghost', label: 'x', type: 'string', sql: 'ghost' }, }, - public: false, + public: true, }; const { service } = makeService({ cubes: [external] }); diff --git a/packages/services/service-analytics/src/__tests__/dotted-measure-refusal.test.ts b/packages/services/service-analytics/src/__tests__/dotted-measure-refusal.test.ts index 0dce4b45f35..107b890f493 100644 --- a/packages/services/service-analytics/src/__tests__/dotted-measure-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/dotted-measure-refusal.test.ts @@ -320,7 +320,7 @@ describe('[#5918] the warm registry gets the same answer as the cold one', () => sql: 'crm_account', measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, dimensions: {}, - public: false, + public: true, }; const { error, sqls } = await run( { cube: 'crm_account', measures: ['owner.region_count_distinct'] }, @@ -382,7 +382,7 @@ describe('[#5918] the surfaces the ruling leaves alone', () => { }, }, dimensions: {}, - public: false, + public: true, }; const { error, sqls } = await run( { cube: 'crm_account', measures: ['owner.amount_sum'] }, diff --git a/packages/services/service-analytics/src/__tests__/filter-array-lowering.test.ts b/packages/services/service-analytics/src/__tests__/filter-array-lowering.test.ts index acaefc7cd3f..02aed489cf2 100644 --- a/packages/services/service-analytics/src/__tests__/filter-array-lowering.test.ts +++ b/packages/services/service-analytics/src/__tests__/filter-array-lowering.test.ts @@ -72,7 +72,7 @@ const CUBE: Cube = { { name: n, label: n, type: n === 'amount' ? 'number' : 'string', sql: n }, ]), ), - public: false, + public: true, } as unknown as Cube; /** Point sql.js at the `.wasm` shipped inside its own package (Node-safe). */ diff --git a/packages/services/service-analytics/src/__tests__/filter-normalizer-not-null-safe.test.ts b/packages/services/service-analytics/src/__tests__/filter-normalizer-not-null-safe.test.ts index 5aad0a15108..d47237422b2 100644 --- a/packages/services/service-analytics/src/__tests__/filter-normalizer-not-null-safe.test.ts +++ b/packages/services/service-analytics/src/__tests__/filter-normalizer-not-null-safe.test.ts @@ -101,7 +101,7 @@ const CUBE: Cube = { { name: n, label: n, type: n === 'amount' ? 'number' : 'string', sql: n }, ]), ), - public: false, + public: true, } as unknown as Cube; /** Point sql.js at the `.wasm` shipped inside its own package (Node-safe). */ diff --git a/packages/services/service-analytics/src/__tests__/filter-operator-coverage.test.ts b/packages/services/service-analytics/src/__tests__/filter-operator-coverage.test.ts index 5dbf0744b00..2e1d4f24909 100644 --- a/packages/services/service-analytics/src/__tests__/filter-operator-coverage.test.ts +++ b/packages/services/service-analytics/src/__tests__/filter-operator-coverage.test.ts @@ -54,7 +54,7 @@ const CUBE: Cube = { name: { name: 'name', label: 'Name', type: 'string', sql: 'name' }, score: { name: 'score', label: 'Score', type: 'number', sql: 'score' }, }, - public: false, + public: true, } as unknown as Cube; /** diff --git a/packages/services/service-analytics/src/__tests__/filter-value-type-fidelity.test.ts b/packages/services/service-analytics/src/__tests__/filter-value-type-fidelity.test.ts index 1d27a9db1c7..99c81736282 100644 --- a/packages/services/service-analytics/src/__tests__/filter-value-type-fidelity.test.ts +++ b/packages/services/service-analytics/src/__tests__/filter-value-type-fidelity.test.ts @@ -300,7 +300,7 @@ const CUBE: Cube = { code: { name: 'code', label: 'Code', type: 'string', sql: 'code' }, score: { name: 'score', label: 'Score', type: 'number', sql: 'score' }, }, - public: false, + public: true, } as unknown as Cube; const ROW_CASES: Array<{ name: string; filter: FilterCondition; expected: string[]; note: string }> = [ diff --git a/packages/services/service-analytics/src/__tests__/icontains-dialect-sql.test.ts b/packages/services/service-analytics/src/__tests__/icontains-dialect-sql.test.ts index 38911e42e3a..da4c34ab668 100644 --- a/packages/services/service-analytics/src/__tests__/icontains-dialect-sql.test.ts +++ b/packages/services/service-analytics/src/__tests__/icontains-dialect-sql.test.ts @@ -104,7 +104,7 @@ const CUBE: Cube = { id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, name: { name: 'name', label: 'Name', type: 'string', sql: 'name' }, }, - public: false, + public: true, } as unknown as Cube; const query = (where: unknown): AnalyticsQuery => diff --git a/packages/services/service-analytics/src/__tests__/icontains-text-comparand-refusal.test.ts b/packages/services/service-analytics/src/__tests__/icontains-text-comparand-refusal.test.ts index 9213983459d..1e1111e8627 100644 --- a/packages/services/service-analytics/src/__tests__/icontains-text-comparand-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/icontains-text-comparand-refusal.test.ts @@ -75,7 +75,7 @@ const CUBE: Cube = { dimensions: Object.fromEntries( [['id', 'string'], ['name', 'string'], ['amt', 'number']].map(([n, t]) => [n, { name: n, label: n, type: t, sql: n }]), ), - public: false, + public: true, } as unknown as Cube; const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } } as never; diff --git a/packages/services/service-analytics/src/__tests__/like-metacharacter-escape.test.ts b/packages/services/service-analytics/src/__tests__/like-metacharacter-escape.test.ts index a41003eadf7..efe37af05c9 100644 --- a/packages/services/service-analytics/src/__tests__/like-metacharacter-escape.test.ts +++ b/packages/services/service-analytics/src/__tests__/like-metacharacter-escape.test.ts @@ -117,7 +117,7 @@ const CUBE: Cube = { id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, name: { name: 'name', label: 'Name', type: 'string', sql: 'name' }, }, - public: false, + public: true, } as unknown as Cube; const query = (where: unknown): AnalyticsQuery => diff --git a/packages/services/service-analytics/src/__tests__/measure-field-and-filter-compilation.test.ts b/packages/services/service-analytics/src/__tests__/measure-field-and-filter-compilation.test.ts index e001d5190ce..18615fd21b6 100644 --- a/packages/services/service-analytics/src/__tests__/measure-field-and-filter-compilation.test.ts +++ b/packages/services/service-analytics/src/__tests__/measure-field-and-filter-compilation.test.ts @@ -230,7 +230,7 @@ describe('[#10298] `/api/v1/analytics/query` compiles every per-measure `filter` // A MANIFEST cube — no dataset registry entry, so there is nothing to // scope by and the emitted statement must be what it always was. cubes: [{ - name: 'crm_case', title: 'Cases', sql: 'crm_case', public: false, + name: 'crm_case', title: 'Cases', sql: 'crm_case', public: true, measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' }, amount_sum: { name: 'amount_sum', label: 'Amount', type: 'sum', sql: 'amount' }, diff --git a/packages/services/service-analytics/src/__tests__/measure-source-field-gate.test.ts b/packages/services/service-analytics/src/__tests__/measure-source-field-gate.test.ts index 6cbc7586243..97a33ea7794 100644 --- a/packages/services/service-analytics/src/__tests__/measure-source-field-gate.test.ts +++ b/packages/services/service-analytics/src/__tests__/measure-source-field-gate.test.ts @@ -231,7 +231,7 @@ describe('#4437 — measure source-field gate', () => { legacy: { name: 'legacy', label: 'Legacy', type: 'sum', sql: 'dropped_column' }, }, dimensions: {}, - public: false, + public: true, }; const { service, aggregated } = makeService({ cubes: [authored] }); @@ -254,7 +254,7 @@ describe('#4437 — measure source-field gate', () => { sql: 'SELECT * FROM showcase_invoice WHERE status = 1', measures: { anything_sum: { name: 'anything_sum', label: 'x', type: 'sum', sql: 'anything' } }, dimensions: {}, - public: false, + public: true, }; const { service } = makeService({ cubes: [derived] }); @@ -285,7 +285,7 @@ describe('#4437 — measure source-field gate', () => { remote_sum: { name: 'remote_sum', label: 'Remote', type: 'sum', sql: 'account.balance' }, }, dimensions: {}, - public: false, + public: true, }; const { service } = makeService({ cubes: [joined] }); @@ -327,7 +327,7 @@ describe('#4437 — measure source-field gate', () => { sql: 'remote_table', measures: { ghost_sum: { name: 'ghost_sum', label: 'x', type: 'sum', sql: 'ghost' } }, dimensions: {}, - public: false, + public: true, }; const { service } = makeService({ cubes: [external] }); diff --git a/packages/services/service-analytics/src/__tests__/missing-column-phrase-hard-failure.test.ts b/packages/services/service-analytics/src/__tests__/missing-column-phrase-hard-failure.test.ts index 23231506c13..7f79783fb15 100644 --- a/packages/services/service-analytics/src/__tests__/missing-column-phrase-hard-failure.test.ts +++ b/packages/services/service-analytics/src/__tests__/missing-column-phrase-hard-failure.test.ts @@ -165,9 +165,11 @@ const CORPUS: Array<[row: string, wording: string, outcome: Outcome]> = [ ['09 rest unknown object', "Object 'ghost' is not registered", 'topology'], [ '10 analytics CUBE_NOT_FOUND (#3867)', - "Cube 'ghost' not found: no cube is registered under that name, and it is not a " + - 'registered object either (a cube can only be auto-inferred from a registered object). ' + - "Define a Cube in your stack, or check the object name.", + "Cube 'ghost' not found: the analytics API exposes no cube under that name, and it is not a " + + 'registered object either (a cube can only be auto-inferred from a registered object). A cube ' + + 'declared `public: false` is hidden from the analytics API and answers exactly like a missing one. ' + + 'Define a Cube in your stack, check the object name, or remove `public: false` from the cube to ' + + 'expose it.', 'empty', ], [ diff --git a/packages/services/service-analytics/src/__tests__/native-sql-datetime-filter-column.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-datetime-filter-column.test.ts index d3917e2bff3..28147b10c08 100644 --- a/packages/services/service-analytics/src/__tests__/native-sql-datetime-filter-column.test.ts +++ b/packages/services/service-analytics/src/__tests__/native-sql-datetime-filter-column.test.ts @@ -41,7 +41,7 @@ const cube: Cube = { assessed: { name: 'assessed', label: 'Assessed', type: 'time', sql: 'assessed_at' }, title: { name: 'title', label: 'Title', type: 'string', sql: 'title' }, }, - public: false, + public: true, }; /** diff --git a/packages/services/service-analytics/src/__tests__/native-sql-datetime-filter.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-datetime-filter.test.ts index a3c8d68618d..2df254b6bef 100644 --- a/packages/services/service-analytics/src/__tests__/native-sql-datetime-filter.test.ts +++ b/packages/services/service-analytics/src/__tests__/native-sql-datetime-filter.test.ts @@ -55,7 +55,7 @@ const cube: Cube = { assessed: { name: 'assessed', label: 'Assessed', type: 'time', sql: 'assessed_at' }, score: { name: 'score', label: 'Score', type: 'number', sql: 'score' }, }, - public: false, + public: true, }; const EPOCH_2025_06_18 = Date.parse('2025-06-18T00:00:00.000Z'); diff --git a/packages/services/service-analytics/src/__tests__/native-sql-filter-logic-conformance.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-filter-logic-conformance.test.ts index 22563cc3a83..4cb26852ce5 100644 --- a/packages/services/service-analytics/src/__tests__/native-sql-filter-logic-conformance.test.ts +++ b/packages/services/service-analytics/src/__tests__/native-sql-filter-logic-conformance.test.ts @@ -57,7 +57,7 @@ const CUBE: Cube = { { name: n, label: n, type: 'string', sql: n }, ]), ), - public: false, + public: true, } as unknown as Cube; /** Point sql.js at the `.wasm` shipped inside its own package (Node-safe). */ diff --git a/packages/services/service-analytics/src/__tests__/native-sql-rls.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-rls.test.ts index 0a93c605a65..379d042f59e 100644 --- a/packages/services/service-analytics/src/__tests__/native-sql-rls.test.ts +++ b/packages/services/service-analytics/src/__tests__/native-sql-rls.test.ts @@ -12,7 +12,7 @@ const cube: Cube = { sql: 'opportunity', measures: { revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' } }, dimensions: { region: { name: 'region', label: 'Region', type: 'string', sql: 'account.region' } }, - public: false, + public: true, }; const query: AnalyticsQuery = { @@ -124,7 +124,7 @@ describe('NativeSQLStrategy — base-column qualification under joins', () => { region: { name: 'region', label: 'Region', type: 'string', sql: 'account.region' }, }, joins: { account: { name: 'account' } }, - public: false, + public: true, }; it('qualifies a base-table dimension with the base table when the cube has joins', async () => { @@ -146,7 +146,7 @@ describe('NativeSQLStrategy — base-column qualification under joins', () => { name: 'tasks', title: 'Tasks', sql: 'task', measures: { c: { name: 'c', label: 'Count', type: 'count', sql: '*' } }, dimensions: { status: { name: 'status', label: 'Status', type: 'string', sql: 'status' } }, - public: false, + public: true, }; const strategy = new NativeSQLStrategy(); const ctx = ctxWith({ getCube: (n) => (n === 'tasks' ? soloCube : undefined) }); @@ -171,7 +171,7 @@ describe('NativeSQLStrategy — multi-hop joins (ADR-0071)', () => { account: { name: 'crm_account' }, 'account__owner': { name: 'core_user' }, }, - public: false, + public: true, }; const mhQuery: AnalyticsQuery = { cube: 'sales', diff --git a/packages/services/service-analytics/src/__tests__/native-sql-temporal-conformance.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-temporal-conformance.test.ts index e741855c51f..6424bed85e8 100644 --- a/packages/services/service-analytics/src/__tests__/native-sql-temporal-conformance.test.ts +++ b/packages/services/service-analytics/src/__tests__/native-sql-temporal-conformance.test.ts @@ -70,7 +70,7 @@ const CUBE: Cube = { at: { name: 'at', label: 'At', type: 'time', sql: 'happened_at' }, on: { name: 'on', label: 'On', type: 'time', sql: 'happened_on' }, }, - public: false, + public: true, } as unknown as Cube; const resolveTokens = (filter: T): T => diff --git a/packages/services/service-analytics/src/__tests__/objectql-contains-canonical-operator.test.ts b/packages/services/service-analytics/src/__tests__/objectql-contains-canonical-operator.test.ts index 8cd5cfb95a8..1bcfe5279cb 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-contains-canonical-operator.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-contains-canonical-operator.test.ts @@ -86,7 +86,7 @@ const CUBE: Cube = { id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' }, }, - public: false, + public: true, } as unknown as Cube; const query = (where: unknown): AnalyticsQuery => diff --git a/packages/services/service-analytics/src/__tests__/objectql-dataset-filter.test.ts b/packages/services/service-analytics/src/__tests__/objectql-dataset-filter.test.ts index 51bed5097e3..8a265835f24 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-dataset-filter.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-dataset-filter.test.ts @@ -429,7 +429,7 @@ describe('[#10413] what the dataset filter must and must not do', () => { return runAggregate(ALL_ROWS, options); }, cubes: [{ - name: 'crm_opportunity', title: 'Opportunities', sql: 'crm_opportunity', public: false, + name: 'crm_opportunity', title: 'Opportunities', sql: 'crm_opportunity', public: true, measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, dimensions: {}, }], diff --git a/packages/services/service-analytics/src/__tests__/objectql-echo-operator-coverage.test.ts b/packages/services/service-analytics/src/__tests__/objectql-echo-operator-coverage.test.ts index 44ba5a52f61..05e760c9161 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-echo-operator-coverage.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-echo-operator-coverage.test.ts @@ -79,7 +79,7 @@ const CUBE: Cube = { { name: n, label: n, type: n === 'amount' ? 'number' : 'string', sql: n }, ]), ), - public: false, + public: true, } as unknown as Cube; /** Point sql.js at the `.wasm` shipped inside its own package (Node-safe). */ diff --git a/packages/services/service-analytics/src/__tests__/objectql-icontains-arm.test.ts b/packages/services/service-analytics/src/__tests__/objectql-icontains-arm.test.ts index 6abc2c83684..047ac5bb0f6 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-icontains-arm.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-icontains-arm.test.ts @@ -73,7 +73,7 @@ const CUBE: Cube = { dimensions: Object.fromEntries( [['id', 'string'], ['name', 'string'], ['amt', 'number']].map(([n, t]) => [n, { name: n, label: n, type: t, sql: n }]), ), - public: false, + public: true, } as unknown as Cube; const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } } as never; diff --git a/packages/services/service-analytics/src/__tests__/objectql-read-scope-vacancy-refusal.test.ts b/packages/services/service-analytics/src/__tests__/objectql-read-scope-vacancy-refusal.test.ts index 1f0aeecef64..541a2615dfa 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-read-scope-vacancy-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-read-scope-vacancy-refusal.test.ts @@ -101,7 +101,7 @@ const CUBE: Cube = { dimensions: Object.fromEntries( ['id', 'owner'].map((n) => [n, { name: n, label: n, type: 'string', sql: n }]), ), - public: false, + public: true, } as unknown as Cube; interface WireBearingError extends Error { diff --git a/packages/services/service-analytics/src/__tests__/query-dataset-request-scope.test.ts b/packages/services/service-analytics/src/__tests__/query-dataset-request-scope.test.ts index 14f93297e44..8f4041e5503 100644 --- a/packages/services/service-analytics/src/__tests__/query-dataset-request-scope.test.ts +++ b/packages/services/service-analytics/src/__tests__/query-dataset-request-scope.test.ts @@ -25,10 +25,10 @@ * - The ADMITTED leg is the negative control a lazy fix loses: the request * must still be served, from ITS definition — the driver sees the request's * object, not the shared cube's. - * - A `public: false` cube keeps its authored definition in the registry. - * Nothing on this tree reads `public` yet, so "stays hidden from meta" is - * not expressible here; what this pins is the precondition that leg needs: - * the entry a visibility filter would read is still the author's. + * - A `public: false` cube keeps its authored definition in the registry, and + * stays hidden: `getMeta` omits it (`cube-visibility.ts`), so the observer's + * discovery snapshot — taken before and after — never lists it, and the + * entry the visibility filter reads is still the author's. * - CONTROL: a dataset registered at construction (`datasets`, the boot door) * still serves by name, and a request under its name leaves its compiled * scope — the definition-level `filter` the shared query applies — intact. @@ -140,7 +140,8 @@ describe.each(STRATEGY_PATHS)('queryDataset leaves the shared registries alone it('observer baseline: the authored cube and the saved dataset serve by name, on their own objects', async () => { const h = makeService(capabilities); const { meta, driven } = await observe(h); - expect(meta.map((c) => c.name).sort()).toEqual(['hidden_summary', 'open_summary', 'saved_summary']); + // `hidden_summary` declares `public: false`, so discovery omits it. + expect(meta.map((c) => c.name).sort()).toEqual(['open_summary', 'saved_summary']); expect(driven.map((c) => c.object)).toEqual(['open_obj', 'open_obj']); // The saved dataset's definition-level filter reaches the driver — the // compiled scope this card must leave in place. diff --git a/packages/services/service-analytics/src/__tests__/read-scope-comparand-three-faces.test.ts b/packages/services/service-analytics/src/__tests__/read-scope-comparand-three-faces.test.ts index 50ca0c931b6..e8b0c857eae 100644 --- a/packages/services/service-analytics/src/__tests__/read-scope-comparand-three-faces.test.ts +++ b/packages/services/service-analytics/src/__tests__/read-scope-comparand-three-faces.test.ts @@ -97,7 +97,7 @@ const CUBE: Cube = { dimensions: Object.fromEntries( ['id', 'region'].map((n) => [n, { name: n, label: n, type: 'string', sql: n }]), ), - public: false, + public: true, } as unknown as Cube; const QUERY = { cube: 'deals', dimensions: ['id'], measures: ['n'] } as AnalyticsQuery; @@ -399,7 +399,7 @@ describe('[#20018] `applyReadScope` judges the JOINED object\'s scope too', () = sql: 'opportunity', measures: { revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' } }, dimensions: { region: { name: 'region', label: 'Region', type: 'string', sql: 'account.region' } }, - public: false, + public: true, }; const ctxWith = (accountScope: unknown): StrategyContext => diff --git a/packages/services/service-analytics/src/__tests__/read-scope-empty-nin-refusal.test.ts b/packages/services/service-analytics/src/__tests__/read-scope-empty-nin-refusal.test.ts index 5bdfe160f8e..db635b7e3d7 100644 --- a/packages/services/service-analytics/src/__tests__/read-scope-empty-nin-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/read-scope-empty-nin-refusal.test.ts @@ -61,7 +61,7 @@ const CUBE: Cube = { dimensions: Object.fromEntries( ['id', 'owner'].map((n) => [n, { name: n, label: n, type: 'string', sql: n }]), ), - public: false, + public: true, } as unknown as Cube; /** Point sql.js at the `.wasm` shipped inside its own package (Node-safe). */ diff --git a/packages/services/service-analytics/src/__tests__/read-scope-eq-array-refusal.test.ts b/packages/services/service-analytics/src/__tests__/read-scope-eq-array-refusal.test.ts index 5bbc337d919..11bc7b919fb 100644 --- a/packages/services/service-analytics/src/__tests__/read-scope-eq-array-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/read-scope-eq-array-refusal.test.ts @@ -154,7 +154,7 @@ const CUBE: Cube = { dimensions: Object.fromEntries( ['id', 'status'].map((n) => [n, { name: n, label: n, type: 'string', sql: n }]), ), - public: false, + public: true, } as unknown as Cube; const QUERY = { cube: 'tickets', dimensions: ['id'], measures: ['n'] } as AnalyticsQuery; diff --git a/packages/services/service-analytics/src/__tests__/read-scope-not-null-safe.test.ts b/packages/services/service-analytics/src/__tests__/read-scope-not-null-safe.test.ts index cbcd23640b7..5b045baa869 100644 --- a/packages/services/service-analytics/src/__tests__/read-scope-not-null-safe.test.ts +++ b/packages/services/service-analytics/src/__tests__/read-scope-not-null-safe.test.ts @@ -93,7 +93,7 @@ const cube: Cube = { sql: 'deal', measures: { revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' } }, dimensions: { stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' } }, - public: false, + public: true, }; const query: AnalyticsQuery = { diff --git a/packages/services/service-analytics/src/__tests__/read-scope-vacancy-three-faces.test.ts b/packages/services/service-analytics/src/__tests__/read-scope-vacancy-three-faces.test.ts index bb38816f580..5e77d843e7e 100644 --- a/packages/services/service-analytics/src/__tests__/read-scope-vacancy-three-faces.test.ts +++ b/packages/services/service-analytics/src/__tests__/read-scope-vacancy-three-faces.test.ts @@ -98,7 +98,7 @@ const CUBE: Cube = { dimensions: Object.fromEntries( ['id', 'owner'].map((n) => [n, { name: n, label: n, type: 'string', sql: n }]), ), - public: false, + public: true, } as unknown as Cube; const QUERY = { cube: 'deals', dimensions: ['id'], measures: ['n'] } as AnalyticsQuery; @@ -345,7 +345,7 @@ describe('[#13926] `applyReadScope` guards the JOINED object\'s scope too', () = sql: 'opportunity', measures: { revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' } }, dimensions: { region: { name: 'region', label: 'Region', type: 'string', sql: 'account.region' } }, - public: false, + public: true, }; const ctxWith = (accountScope: unknown): StrategyContext => diff --git a/packages/services/service-analytics/src/__tests__/sql-dialect-vocabulary.test.ts b/packages/services/service-analytics/src/__tests__/sql-dialect-vocabulary.test.ts index 938d60a7ad7..9d31e7d327a 100644 --- a/packages/services/service-analytics/src/__tests__/sql-dialect-vocabulary.test.ts +++ b/packages/services/service-analytics/src/__tests__/sql-dialect-vocabulary.test.ts @@ -95,7 +95,7 @@ const CUBE: Cube = { id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, name: { name: 'name', label: 'Name', type: 'string', sql: 'name' }, }, - public: false, + public: true, } as unknown as Cube; /** diff --git a/packages/services/service-analytics/src/__tests__/sql-echo-debug-gate.test.ts b/packages/services/service-analytics/src/__tests__/sql-echo-debug-gate.test.ts index 5568b902cf5..df59f81cfc4 100644 --- a/packages/services/service-analytics/src/__tests__/sql-echo-debug-gate.test.ts +++ b/packages/services/service-analytics/src/__tests__/sql-echo-debug-gate.test.ts @@ -62,7 +62,7 @@ const usersCube: Cube = { dimensions: { id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, }, - public: false, + public: true, }; /** diff --git a/packages/services/service-analytics/src/__tests__/text-match-sqlite-nul.test.ts b/packages/services/service-analytics/src/__tests__/text-match-sqlite-nul.test.ts index 8b6afe29e67..b6590e5b1ab 100644 --- a/packages/services/service-analytics/src/__tests__/text-match-sqlite-nul.test.ts +++ b/packages/services/service-analytics/src/__tests__/text-match-sqlite-nul.test.ts @@ -167,7 +167,7 @@ const CUBE: Cube = { label: { name: 'label', label: 'Label', type: 'string', sql: 'label' }, v: { name: 'v', label: 'V', type: 'string', sql: 'v' }, }, - public: false, + public: true, } as unknown as Cube; const query = (where: unknown): AnalyticsQuery => diff --git a/packages/services/service-analytics/src/__tests__/text-operator-case-exactness.test.ts b/packages/services/service-analytics/src/__tests__/text-operator-case-exactness.test.ts index 7e3fa8750fc..8e915e8b126 100644 --- a/packages/services/service-analytics/src/__tests__/text-operator-case-exactness.test.ts +++ b/packages/services/service-analytics/src/__tests__/text-operator-case-exactness.test.ts @@ -178,7 +178,7 @@ const CUBE: Cube = { id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, name: { name: 'name', label: 'Name', type: 'string', sql: 'name' }, }, - public: false, + public: true, } as unknown as Cube; /** A second cube over the GLOB-metacharacter fixture — see `GLOB_ROWS`. */ diff --git a/packages/services/service-analytics/src/__tests__/text-operator-non-text-column.test.ts b/packages/services/service-analytics/src/__tests__/text-operator-non-text-column.test.ts index 3460829a0d1..5647ac47396 100644 --- a/packages/services/service-analytics/src/__tests__/text-operator-non-text-column.test.ts +++ b/packages/services/service-analytics/src/__tests__/text-operator-non-text-column.test.ts @@ -74,7 +74,7 @@ const CUBE: Cube = { name: { name: 'name', label: 'Name', type: 'string', sql: 'name' }, score: { name: 'score', label: 'Score', type: 'number', sql: 'score' }, }, - public: false, + public: true, } as unknown as Cube; const query = (where: unknown): AnalyticsQuery => diff --git a/packages/services/service-analytics/src/__tests__/unlisted-refusal-envelope.test.ts b/packages/services/service-analytics/src/__tests__/unlisted-refusal-envelope.test.ts index d9bd2da11bb..878765de469 100644 --- a/packages/services/service-analytics/src/__tests__/unlisted-refusal-envelope.test.ts +++ b/packages/services/service-analytics/src/__tests__/unlisted-refusal-envelope.test.ts @@ -171,7 +171,7 @@ const joinedCube: Cube = { joins: { account: { name: 'account' }, }, - public: false, + public: true, }; /** A cube declaring exactly ONE measure — so `revenue` is undeclared on it. */ @@ -181,7 +181,7 @@ const countOnlyCube: Cube = { sql: 'crm_opportunity', measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, dimensions: { stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' } }, - public: false, + public: true, }; function ctxFor(cube: Cube): StrategyContext { diff --git a/packages/services/service-analytics/src/__tests__/where-boolean-flag-refusal.test.ts b/packages/services/service-analytics/src/__tests__/where-boolean-flag-refusal.test.ts index f36a77aa67a..2d86018753e 100644 --- a/packages/services/service-analytics/src/__tests__/where-boolean-flag-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/where-boolean-flag-refusal.test.ts @@ -74,7 +74,7 @@ const CUBE: Cube = { dimensions: Object.fromEntries( [['id', 'string'], ['stage', 'string'], ['amt', 'number']].map(([n, t]) => [n, { name: n, label: n, type: t, sql: n }]), ), - public: false, + public: true, } as unknown as Cube; const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } } as never; diff --git a/packages/services/service-analytics/src/__tests__/where-equality-slot-list-refusal.test.ts b/packages/services/service-analytics/src/__tests__/where-equality-slot-list-refusal.test.ts index 3d705930101..9f7068302e4 100644 --- a/packages/services/service-analytics/src/__tests__/where-equality-slot-list-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/where-equality-slot-list-refusal.test.ts @@ -239,7 +239,7 @@ const CUBE: Cube = { dimensions: Object.fromEntries( ['id', 'stage'].map((n) => [n, { name: n, label: n, type: 'string', sql: n }]), ), - public: false, + public: true, } as unknown as Cube; describe('[#19888] every analytics face refuses before anything runs (real engine)', () => { diff --git a/packages/services/service-analytics/src/__tests__/where-face-arms-refusal.test.ts b/packages/services/service-analytics/src/__tests__/where-face-arms-refusal.test.ts index 09852e15e92..380f86bb575 100644 --- a/packages/services/service-analytics/src/__tests__/where-face-arms-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/where-face-arms-refusal.test.ts @@ -307,7 +307,7 @@ const CUBE: Cube = { dimensions: Object.fromEntries( [['id', 'string'], ['amt', 'number'], ['stage', 'string']].map(([n, t]) => [n, { name: n, label: n, type: t, sql: n }]), ), - public: false, + public: true, } as unknown as Cube; describe('[#20010] every analytics face refuses before anything runs (real engine)', () => { diff --git a/packages/services/service-analytics/src/__tests__/where-source-field-gate.test.ts b/packages/services/service-analytics/src/__tests__/where-source-field-gate.test.ts index d3aac4e0f56..6cf54fbfe99 100644 --- a/packages/services/service-analytics/src/__tests__/where-source-field-gate.test.ts +++ b/packages/services/service-analytics/src/__tests__/where-source-field-gate.test.ts @@ -341,7 +341,7 @@ describe('#5669 — the gate: a `where` over a missing field is a 400, not a dri industry: { name: 'industry', label: 'Industry', type: 'string', sql: 'industry' }, legacy: { name: 'legacy', label: 'Legacy', type: 'string', sql: 'dropped_column' }, }, - public: false, + public: true, }; const { service, aggregated } = makeService({ cubes: [authored] }); @@ -442,7 +442,7 @@ describe('#5669 — the dataset face: refused before SQL exists, so nothing can sql: 'SELECT * FROM crm_account', measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, dimensions: {}, - public: false, + public: true, }; const { service } = makeService({ cubes: [derived], native: true }); @@ -514,7 +514,7 @@ describe('#5669 — what the gate must NOT do', () => { dimensions: { assessed: { name: 'assessed', label: 'Assessed', type: 'time', sql: 'assessed_at' }, }, - public: false, + public: true, }; const { service, aggregated, filters } = makeService({ cubes: [authored] }); @@ -542,7 +542,7 @@ describe('#5669 — what the gate must NOT do', () => { revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'annual_revenue' }, }, dimensions: {}, - public: false, + public: true, }; const { service, aggregated, filters } = makeService({ cubes: [authored] }); @@ -571,7 +571,7 @@ describe('#5669 — what the gate must NOT do', () => { sql: 'SELECT * FROM crm_account WHERE active = 1', measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, dimensions: {}, - public: false, + public: true, }; const { service } = makeService({ cubes: [derived] }); @@ -604,7 +604,7 @@ describe('#5669 — what the gate must NOT do', () => { sql: 'crm_account', measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, dimensions: {}, - public: false, + public: true, }; const { service } = makeService({ cubes: [joined] }); @@ -636,7 +636,7 @@ describe('#5669 — what the gate must NOT do', () => { sql: 'crm_account', measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, dimensions: {}, - public: false, + public: true, }; const { service } = makeService({ cubes: [joined] }); @@ -707,7 +707,7 @@ describe('#5669 — what the gate must NOT do', () => { sql: "CASE WHEN annual_revenue > 0 THEN 'yes' ELSE 'no' END", }, }, - public: false, + public: true, }; const { service } = makeService({ cubes: [computed] }); diff --git a/packages/services/service-analytics/src/__tests__/where-type-face-refusal.test.ts b/packages/services/service-analytics/src/__tests__/where-type-face-refusal.test.ts index 9b8535030a7..54d83013f69 100644 --- a/packages/services/service-analytics/src/__tests__/where-type-face-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/where-type-face-refusal.test.ts @@ -313,7 +313,7 @@ const CUBE: Cube = { dimensions: Object.fromEntries( [['id', 'string'], ['amt', 'number'], ['stage', 'string']].map(([n, t]) => [n, { name: n, label: n, type: t, sql: n }]), ), - public: false, + public: true, } as unknown as Cube; const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } } as never; diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 26a980464f3..3c809aa617c 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -33,6 +33,7 @@ import { // docblock for why the edge is acyclic and why it was worth adding. import { matchMissingColumnOfRelation } from '@objectstack/types'; import { CubeRegistry } from './cube-registry.js'; +import { cubeNotFoundError, isCubePublic } from './cube-visibility.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. @@ -1440,6 +1441,15 @@ export class AnalyticsService implements IAnalyticsService { if (!queryInput.cube) { throw new Error('Cube name is required in analytics query'); } + // `analytics_cube.public` — first, ahead of token resolution, cube + // inference, admission and every strategy: a hidden cube is refused + // whatever else the request carries, and the refusal leaves the registry + // exactly as it found it. Asked of `scope`, the same scope that answers + // the name below: a `queryDataset` call's own compiled cube (visible) + // answers its name there, so a dataset named like a hidden configured + // cube runs as itself instead of being refused — a refusal there would + // be an oracle for which names are hidden. + this.assertCubePublic(queryInput.cube, scope); // [#12230] Expand `{current_user_id}` / date-macro placeholders at THIS // seam — before strategy selection — so every strategy compiles the same @@ -2120,12 +2130,16 @@ export class AnalyticsService implements IAnalyticsService { /** * Get cube metadata for discovery. + * + * Only cubes the analytics API exposes are listed: a cube declared + * `public: false` is omitted, and asking for it by name answers `[]` — the + * same answer as a name no cube has (`cube-visibility.ts`). */ async getMeta(cubeName?: string): Promise { - // If a fallback service is configured, merge its metadata with the registry - const cubes = cubeName + const cubes = (cubeName ? [this.cubeRegistry.get(cubeName)].filter(Boolean) as Cube[] - : this.cubeRegistry.getAll(); + : this.cubeRegistry.getAll() + ).filter(isCubePublic); return cubes.map(cube => ({ name: cube.name, @@ -2150,6 +2164,9 @@ export class AnalyticsService implements IAnalyticsService { if (!queryInput.cube) { throw new Error('Cube name is required for SQL generation'); } + // 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. + this.assertCubePublic(queryInput.cube, this.sharedScope); // [#12230] Same token seam as `query()` — the dry-run door must show the // statement that would actually run (a resolved user id in the params, or @@ -2168,6 +2185,22 @@ export class AnalyticsService implements IAnalyticsService { // ── Internal ───────────────────────────────────────────────────── + /** + * Refuse a query against a cube declared `public: false` with the SAME + * refusal an unknown name gets — `cubeNotFoundError`, byte for byte + * (`cube-visibility.ts` says why). + * A name with no registered cube passes: it is the ad-hoc path's to infer or + * refuse (`assertInferableCube`), and every cube that path mints is visible. + * + * The name is resolved through `scope` — the scope the rest of the call + * resolves it through — never the shared registry directly, so the gate and + * the query can never be asking about two different cubes. + */ + private assertCubePublic(name: string, scope: CubeScope): void { + const cube = scope.getCube(name); + if (cube && !isCubePublic(cube)) throw cubeNotFoundError(name); + } + /** * Ensure a cube exists for the given query and that it knows about every * measure referenced by the query. @@ -2651,9 +2684,11 @@ export class AnalyticsService implements IAnalyticsService { * * Rejects with `status: 404` / `code: 'CUBE_NOT_FOUND'` so the HTTP boundary * answers "no such cube" instead of letting the name reach the driver as a - * table and surfacing whatever the driver says about it. The message names - * both ways the request could be made valid, because from here the two are - * genuinely indistinguishable: register a Cube, or register the object. + * table and surfacing whatever the driver says about it. The refusal is + * `cubeNotFoundError`, shared byte for byte with the hidden-cube refusal, and + * its message names every way the request could be made valid, because to + * the caller they are deliberately indistinguishable: register a Cube, + * register the object, or remove a `public: false` that hides the cube. * * Skips when `isRegisteredObject` was not supplied — see the config field's * doc for why that tier is a deliberate stand-down and not a hole. @@ -2672,15 +2707,9 @@ export class AnalyticsService implements IAnalyticsService { return; } if (isRegisteredObject(name)) return; - const err = new Error( - `Cube '${name}' not found: no cube is registered under that name, and it is not a ` + - `registered object either (a cube can only be auto-inferred from a registered object). ` + - `Define a Cube in your stack, or check the object name.`, - ) as Error & { code?: string; status?: number; cube?: string }; - err.code = 'CUBE_NOT_FOUND'; - err.status = 404; - err.cube = name; - throw err; + // The SAME refusal a hidden cube gets (`assertCubePublic`): a caller must + // not be able to tell "hidden" from "absent" (`cube-visibility.ts`). + throw cubeNotFoundError(name); } /** Build a minimal Cube from the fields referenced by an AnalyticsQuery. */ @@ -2816,7 +2845,10 @@ export class AnalyticsService implements IAnalyticsService { sql: cubeName, measures, dimensions, - public: false, + // Visible: this cube is minted FOR the request that names it and is + // registered, so the next request resolves it from the registry — where + // a hidden verdict would refuse the very KPI path that minted it. + public: true, }; } diff --git a/packages/services/service-analytics/src/cube-registry.ts b/packages/services/service-analytics/src/cube-registry.ts index 8ffb0eacd3c..fb871b8e123 100644 --- a/packages/services/service-analytics/src/cube-registry.ts +++ b/packages/services/service-analytics/src/cube-registry.ts @@ -162,7 +162,8 @@ export class CubeRegistry { sql: objectName, measures, dimensions, - public: false, + // The schema default (visible) — a hidden cube is refused by every query door. + public: true, }; this.register(cube); diff --git a/packages/services/service-analytics/src/cube-visibility.ts b/packages/services/service-analytics/src/cube-visibility.ts new file mode 100644 index 00000000000..12a600332b5 --- /dev/null +++ b/packages/services/service-analytics/src/cube-visibility.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Cube } from '@objectstack/spec/data'; + +/** + * The reader of `analytics_cube.public` — the one place the analytics API asks + * whether a registered cube is exposed. + * + * ## What the key means (the Cube.dev semantics the schema follows) + * + * A cube declared `public: false` is hidden from the analytics API: discovery + * (`getMeta`, `GET /analytics/meta`) omits it, and every door that runs a query + * against a named cube refuses it — `query()` (`POST /analytics/query`) and + * `generateSql()` (`POST /analytics/sql`). The dataset door's queries run + * through the same gate (`queryIn`), asked of the call's own request scope, so + * a dataset's compiled cube — visible — answers its own name there. Anything + * else is visible; the schema default is `true`. + * + * It is VISIBILITY, not row security. An object's records stay governed by the + * object's permissions and row-level security on every door (`assertReadAdmitted` + * and the read scope in `analytics-service.ts`), whether or not some cube over + * that object is hidden — a caller who may read the object may still aggregate it + * through an ad-hoc cube named after the object. What `false` does, and all it + * does: the cube is left out of `/analytics/meta`, and queries and SQL + * generation against it are refused. It does NOT hide the cube's definition + * from the metadata door — authored cubes are also `analytics_cube` metadata + * items, readable there like any other authored schema (the Cube.dev split: + * `public` governs the query API, not the schema files). + * + * ## Why `=== false` and not a truthiness test + * + * The registry holds the INPUT shape (`Cube` is `z.input`): + * `CubeRegistry.register` never parses, and the internal producers + * (`inferCubeFromQuery`, `compileDataset`, `CubeRegistry.inferFromObject`) build + * typed literals. So an absent key reaches this function as `undefined`, and the + * reading of an absent key is the schema's default — visible. Only the explicit + * value the author wrote hides; `analytics.test.ts` in `@objectstack/spec` pins + * that default, so the two cannot drift apart silently. + * + * ## Why the refusal is the unknown-cube refusal, byte for byte + * + * To an API consumer a hidden cube is exactly as absent as one nobody declared: + * discovery omits both, so the query doors answer both with ONE refusal — + * {@link cubeNotFoundError}, the same `CUBE_NOT_FOUND` / 404 envelope and the + * same message — which `analytics-service.ts` throws from both paths + * (`assertCubePublic` for a hidden cube, `assertInferableCube` for a name that + * is neither a cube nor an object). A caller who guesses a name therefore + * cannot tell a hidden cube from a missing one by status, code or text; the + * message names both possibilities, so the author who wrote the flag still + * reads how to expose the cube. `cube-public-visibility.test.ts` pins the + * identity. It is never an empty result: a query that silently returned no rows + * would read as "no data" on a dashboard. + */ +export function isCubePublic(cube: Cube): boolean { + return cube.public !== false; +} + +/** + * The ONE `CUBE_NOT_FOUND` / 404 refusal for a name the analytics API does not + * expose — thrown for a hidden cube and for a name that is neither a registered + * cube nor a registered object, so the two are indistinguishable to a caller. + * + * ⛔ Keep the phrase "is not a registered object" in the message: + * `isMissingSourceError` (`analytics-service.ts`) reads it, and the dataset + * door's envelope check is what stops that read from turning this 404 into an + * empty grid (`dataset-degradation-envelope.test.ts`). + */ +export function cubeNotFoundError(name: string): Error & { code: string; status: number; cube: string } { + const err = new Error( + `Cube '${name}' not found: the analytics API exposes no cube under that name, and it is not a ` + + `registered object either (a cube can only be auto-inferred from a registered object). A cube ` + + `declared \`public: false\` is hidden from the analytics API and answers exactly like a missing one. ` + + `Define a Cube in your stack, check the object name, or remove \`public: false\` from the cube to ` + + `expose it.`, + ) as Error & { code: string; status: number; cube: string }; + err.code = 'CUBE_NOT_FOUND'; + err.status = 404; + err.cube = name; + return err; +} diff --git a/packages/services/service-analytics/src/dataset-compiler.ts b/packages/services/service-analytics/src/dataset-compiler.ts index 82a7a2b8aa4..ae9fad0d7e2 100644 --- a/packages/services/service-analytics/src/dataset-compiler.ts +++ b/packages/services/service-analytics/src/dataset-compiler.ts @@ -686,7 +686,10 @@ export function compileDataset( sql: dataset.object, measures, dimensions, - public: false, + // Visible: `queryDataset` runs the selection through `DatasetExecutor`, + // which reaches this cube by name through `AnalyticsService.query()` — the + // door that refuses a hidden cube (`cube-visibility.ts`). + public: true, }; if (Object.keys(joins).length > 0) cube.joins = joins; diff --git a/packages/spec/authorable-defaults/data.json b/packages/spec/authorable-defaults/data.json index 0b2ec40e04f..9918bb5dceb 100644 --- a/packages/spec/authorable-defaults/data.json +++ b/packages/spec/authorable-defaults/data.json @@ -10,7 +10,7 @@ "data/CrossFieldValidation:events = [\"insert\",\"update\"]", "data/CrossFieldValidation:priority = 100", "data/CrossFieldValidation:severity = \"error\"", - "data/Cube:public = false", + "data/Cube:public = true", "data/CurrencyConfig:currencyMode = \"dynamic\"", "data/CurrencyConfig:defaultCurrency = \"CNY\"", "data/DataEngineDeleteOptions:multi = false", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 437a3c71d15..35ed7ffb701 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -940,7 +940,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | realtime_subscription | seeded 2026-09-04 (#14446) — a TRANSPORT-PROTOCOL surface, the fifth category the `SPEC_ONLY_SCHEMAS` override has had to reach. `SubscriptionSchema` (`packages/spec/src/api/realtime.zod.ts`) is what a client declares to open a realtime subscription: the item type of `RealtimeConfigSchema.subscriptions` and the `Subscription` the generated API reference publishes. Like `query` it is a request surface rather than stored metadata, and like `query` that is exactly why it went unasked — no registry holds it, `RealtimeConfigSchema` is `.passthrough()` so nothing downstream even refuses an unknown key, and the whole vocabulary sat outside the denominator while the reference kept publishing it. Rooted on `SubscriptionSchema` rather than on `RealtimeConfigSchema` for the reason the four `RestServerConfig` sub-objects document one row up: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with the config as the root `events[].type` and `events[].filters` would inherit a container verdict instead of carrying rows of their own — #4956's shape. **Dead 6 = every key it has, and the CONTAINER is the finding**: nothing outside `packages/spec` imports `SubscriptionSchema`, `SubscriptionEventSchema` or `RealtimeConfigSchema` at all, so no key beneath them can be read (the `manifest.contributes` reasoning). The two keys the card measured are the sharp ones. `events[].type` accepts `RealtimeEventType`, whose four members (`record.created` / `record.updated` / `record.deleted` / `field.changed`) are DISJOINT from what the engine publishes (`DataEventType`'s `data.record.*`, live emitter in `service-knowledge`), so an author who writes the enum's own `record.created` gets a subscription that silently never fires — and the enum is what the API reference shows them. Its direction is settled by the 2026-09-02 triage and quoted verbatim in the row: enforce means REPOINTING THE ENUM, never changing what the runtime publishes. `field.changed` is the same spelling the sibling `DataEventType` REMOVED in 17.0.0 (#4673, PR #4685) for having no producer; it survives here only because this enum was never in a ratchet's denominator. `events[].filters` is `z.unknown().optional()` — the textbook ADR-0049 fourth state, no shape and no reader, failing in the permissive direction (a subscriber who filters receives every event). ⚠️ Three spellings of a realtime subscription exist and only the third is executed: this one, `websocket.zod.ts#EventSubscriptionSchema`, and the plain interface `contracts/realtime-service.ts#RealtimeSubscriptionOptions` that `in-memory-realtime-adapter.ts#matchesSubscription` actually reads. The file note names the same-name-different-shape traps so the next census does not mistake one for a consumer. Zero live | | sharing_rule | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, and the first one PAID (`connector` and `analytics_cube` are still owed on that card). Not a registered kind: it is bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reaches the walk through `getMetadataTypeSchema`'s unregistered-kind fallback, so this ledger governs a type `listMetadataTypeSchemaTypes()` still does not enumerate. One shape fact decides every row: the AUTHORING shape is not the ENFORCED shape. ADR-0057 D6 makes the `sys_sharing_rule` row canonical (`object_name` + `criteria_json` + `recipient_type`/`recipient_id` + `access_level`) and `bootstrapDeclaredSharingRules` translates each authored key into it at boot — nothing re-parses `SharingRuleSchema` at enforcement time — so every consumer cited reads a COLUMN and every row carries the `producer` (#4837) that populates it, which is the `seed.env` lesson applied to a whole type rather than to one key. Preview read points ENUMERATED per the #7131 rule and the answer recorded rather than skipped: `registerBuiltinPreviews()` (objectui @dda8f381) registers twenty types and `sharing_rule` is not one of them; what objectui does consume is the whole shape, on the CREATE door only (`AUTHOR_SHAPE_ONLY_TYPES` — the EDIT door is deliberately ungated because a served body carries the `_diagnostics` decoration this `.strict()` schema rejects). The single non-`live` row is `type`, the `SharingRuleType` discriminator: one member, `criteria`, whose only reader is a defensive `=== 'owner'` comparison that is unreachable for every value the schema admits. `planned` on the `action.operation` precedent (a one-member discriminator held `planned` until a runtime half dispatched on it, #15080), and deliberately NOT an enforce-or-remove candidate: the key is required, so removing it would break every authored rule to delete nothing. | | connector | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, paid in the same diff as `analytics_cube`, which empties that map. Not a registered kind: bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reached through `getMetadataTypeSchema`'s unregistered-kind fallback. **What the walk actually resolves, measured:** the binding names `DeclarativeConnectorEntrySchema`. ⚠️ The MECHANISM changed with the `connectionTimeoutMs` retirement and the prior sentence here is corrected rather than carried: that schema USED TO BE `ConnectorSchema.superRefine(...)`, a Zod 4 check attached to the same object def, and the key-set conclusion used to rest on that attachment. It is now a `z.preprocess` PIPE — both published carriers wrap one shared private `ConnectorBaseSchema` in the ADR-0049 retired-default residue stage, the entry schema adding the ADR-0097 cross-field rules on the base before wrapping, so the two are SIBLINGS rather than parent and child, and what preserves the walked shape is the pipe's read-through `shape`, NOT a `superRefine` attachment. The CONCLUSION is unchanged and re-measured on the built entry rather than inherited: both carriers expose 30 keys and the key sets are byte-identical, with no entry-only and no base-only key. The gate cannot tell the two schemas apart; what the entry schema buys is REFUSALS, invisible to the walk and visible only in the three rows where they are the whole verdict. **ONE SCHEMA, TWO DOORS** is the shape fact behind the 29/1/30 split (live/planned/dead; counts read from the generated `state-counts.md` row, never hand-kept here): the ledger's denominator entry exists for the AUTHORING doors (`defineStack({ connectors })`, `PUT /meta/connector/:name`), while the same `ConnectorSchema` is what `AutomationEngine.registerConnector` parses for a def a PLUGIN or an ADR-0097 provider factory builds in code — so a key can have a real consumer and still do nothing when a metadata author writes it. The keys an authored entry can reach are exactly the author-supplied `ConnectorProviderContext` fields plus `provider` and `enabled` — `name` is itself one of those fields (the former "plus `name`" tail double-counted it), `loadPackageFile` is host-injected rather than authored, and `provider` selects the factory without ever reaching the context; `type` and `icon` reach that context and are dropped by all three shipped factories, and each says so on its own row. `authentication` is the ledger's `planned`, and ⛔ NOT "refused outright" — the former tail here said exactly that and all three instruments contradict it, including the one it cites: the KEY is ACCEPTED (`connector.zod.ts` declares `authentication: ConnectorAuthConfigSchema.optional().default({ type: 'none' })`, and the accepted value does nothing); what #7990 refuses is a non-`none` VALUE (`if (entry.authentication && entry.authentication.type !== 'none')`, whose own message prescribes "drop `authentication` (or set `{ type: 'none' }`)"); and ADR-0097 §3, titled "Credentials are references", rejects **inline secrets** in stack metadata, not the key. Accepted-and-ignored, plus a loud refusal of every value but `{ type: 'none' }`, is exactly the basis of the `planned` verdict — which the row itself already stated ("the accepted value does nothing"), so the summary, not the row, was the wrong half. The 30 `dead`, re-measured at this head and partitioned so every row is counted exactly once: two declared subsystems with no engine — `syncConfig` (8), `fieldMappings` (7) — plus `triggers` (6, and the schema's own docblock says so: #3197), `metadata`, `actions.description`/`.outputSchema`, and the six top-level `retiredKey` tombstones `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status` and `webhooks`. That sums to 30, the dead count the generated `state-counts.md` row carries. ⚠️ It was 44 until the connector resilience family was retired (ADR-0049): `health` counted 15 drilled rows (both sub-blocks plus the `monitoringWindow` tombstone) and is now ONE leaf tombstone row — the gate refuses `children` under a property that is no longer a container — and `webhooks` left the undrilled baseline for the same reason; `status` and `webhooks` stayed one row each and changed only from dead-awaiting-a-decision to dead-and-tombstoned. ⚠️ `retryConfig` IS NO LONGER IN THIS LIST: all eight of its sub-keys went `live` when #18975 made the declared policy execute at the one platform fetch site, which is the same measurement the falsification note at the end of this row records — so a reader who still finds "`retryConfig` (8)" among the dead is reading a stale copy. ⚠️ Nor is it "the two timeouts" any more: `requestTimeoutMs` is `live` (it becomes `resilientFetch`'s per-attempt deadline) and `connectionTimeoutMs` is the retired tombstone named above. ⭐ EIGHT rows in this ledger are `retiredKey` tombstones that keep their rows because the key stays in the walked shape (the `rls.priority` precedent) — `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status`, `webhooks`, `fieldMappings.transform` and `triggers.interval` — but ⛔ that eight is NOT a separate addend: the first six ARE the top-level tombstones counted above and the last two are already inside the `fieldMappings` and `triggers` counts, which is exactly the double-count that made the previous "and four `retiredKey` tombstones" tail drift. (`health.circuitBreaker.monitoringWindow` was the ninth until its block left whole with `health`.) Count them by name, never by adding the tail. **A prior in-repo claim is recorded here with its DIRECTION measured rather than remembered, because this row's job is the history of how the type got here**: the conversion registry's note inside `connector-rate-limit-config-removed`'s fixture reads "`retryConfig` and the timeouts beside it are untouched by THIS conversion — a statement about its scope, not a liveness verdict. They are not live: declared, defaulted and documented, and read by nothing." ⚠️ It asserts they are NOT live, and it scopes "untouched" to that one conversion. The former tail here quoted it as asserting the OPPOSITE ("they are live") and called it false when seeded — an inversion that turned this whole passage upside down, and it is corrected rather than carried. Measured direction: the note was TRUE when this ledger was seeded (2026-09-17) and is STALE now, #18975 having made the declared policy execute at the one platform fetch site (`connectorFetchOptions` → `resilientFetch`), so `retryConfig`'s eight sub-keys are `live` on their own rows and `requestTimeoutMs` is `live` beside them; only `connectionTimeoutMs` still answers to it, as the retired tombstone. ⛔ The stale comment is not rewritten from here — it is #19729's, as a dated note beside it — and it is not a line this PR's diff touches. ⚠️ The seeding note's supporting census — "the word does not occur outside `packages/spec` at all" — is FALSE at this head and is corrected rather than carried: `git grep -n retryConfig 14fdebd766 -- . ':!packages/spec'` returns 67 **matching lines** over 15 files — `git grep -o` on the same tree and pathspec returns 77 **occurrences**, and a line is not an occurrence, which is the trap a re-measurer falls into next (26 matching lines in the materializer `packages/services/service-automation/src/plugin.ts` and its materialization test, 22 across `connector-rest` and `connector-openapi` — providers, connectors and their tests — 13 in five `.changeset` fragments, and 6 on two `content/docs` pages). ⛔ Re-read that as the standing lesson of this row: a census is a count plus the tree it was taken against, and a bare "does not occur" with no commit behind it is the shape that rots first. The timeouts half is settled on its own rows: `requestTimeoutMs` is `live`, `connectionTimeoutMs` is retired | -| analytics_cube | seeded 2026-09-17 (#18582) — the third debt, paid in the same diff as `connector`. Not a registered kind either: bound in `UNREGISTERED_KIND_SCHEMAS` by #10194 and reached through the same unregistered-kind fallback. **ONE Cube shape, THREE producers, one registry** is what decides every row: `cube-registry.ts` names them itself — authored cubes (`analyticsCubes[]` / `defineCube()`, threaded by the CLI into `AnalyticsServiceConfig.cubes`), COMPILED DATASETS (ADR-0021, where `dataset-compiler` mints a Cube), and ad-hoc query inference. Only the first is the authoring door governed here, so a key whose only reader sits on the compiled-dataset path is not live for an authored cube however busy that reader is — the #4837 producer rule on a shape with three producers. That is `dimensions.granularities` (read by `dataset-executor#granularityOf`, whose argument is a `CompiledDataset` an authored cube never becomes) and `measures.format` (written by the compiler, threaded to the wire from the DATASET measure instead). The query path is genuinely live: `sql` is the FROM table AND the object whose RLS read scope is injected, `measures.type` picks the aggregate, `measures.sql`/`dimensions.sql` the column, `joins[].name` the joined table. The 10 `dead` are the caching block (`refreshKey.every`/`.sql` — no refresh scheduler exists anywhere), the access-control flag (`public` — three sites write `false`, nothing reads it: a knob that was never wired, not a hole that was opened), the three `description`s, and the inner `name` on each of `measures`/`dimensions`, where the record KEY is the identity. It was 12 until #18612 RETIRED `joins[].relationship` and the REQUIRED `joins[].sql` (ADR-0049 enforce-or-remove, maintainer-ruled batch #154): the ON clause is SYNTHESISED as an FK equality and the authored one was never consulted, so a declared join condition came back REPLACED under a 200. `CubeJoinSchema` is a `strictObject`, so the route was strict deletion plus a `guidance` prescription and the two rows left this ledger with the keys — not the `retiredKey()` route, which keeps the row. **#10238 is not prejudged**: whether cube authoring is live end to end is still its own measurement — this ledger answers the per-key question only | +| analytics_cube | seeded 2026-09-17 (#18582) — the third debt, paid in the same diff as `connector`. Not a registered kind either: bound in `UNREGISTERED_KIND_SCHEMAS` by #10194 and reached through the same unregistered-kind fallback. **ONE Cube shape, THREE producers, one registry** is what decides every row: `cube-registry.ts` names them itself — authored cubes (`analyticsCubes[]` / `defineCube()`, threaded by the CLI into `AnalyticsServiceConfig.cubes`), COMPILED DATASETS (ADR-0021, where `dataset-compiler` mints a Cube), and ad-hoc query inference. Only the first is the authoring door governed here, so a key whose only reader sits on the compiled-dataset path is not live for an authored cube however busy that reader is — the #4837 producer rule on a shape with three producers. That is `dimensions.granularities` (read by `dataset-executor#granularityOf`, whose argument is a `CompiledDataset` an authored cube never becomes) and `measures.format` (written by the compiler, threaded to the wire from the DATASET measure instead). The query path is genuinely live: `sql` is the FROM table AND the object whose RLS read scope is injected, `measures.type` picks the aggregate, `measures.sql`/`dimensions.sql` the column, `joins[].name` the joined table. The 9 `dead` are the caching block (`refreshKey.every`/`.sql` — no refresh scheduler exists anywhere), the three `description`s, `measures.format`, `dimensions.granularities`, and the inner `name` on each of `measures`/`dimensions`, where the record KEY is the identity. **#20282** flips the tenth, the visibility flag `public`, `dead` → `live` 2026-09-27: seeded as a knob that was never wired (three internal mints wrote `false`, nothing read it), it is now read by `service-analytics`' `cube-visibility.ts#isCubePublic` — `getMeta` omits a hidden cube and `query()` / `generateSql()` refuse it — in the same change that moved its default from `false` to the Cube.dev `true`, since enforcing the old default would have hidden every authored cube. It was 12 until #18612 RETIRED `joins[].relationship` and the REQUIRED `joins[].sql` (ADR-0049 enforce-or-remove, maintainer-ruled batch #154): the ON clause is SYNTHESISED as an FK equality and the authored one was never consulted, so a declared join condition came back REPLACED under a 200. `CubeJoinSchema` is a `strictObject`, so the route was strict deletion plus a `guidance` prescription and the two rows left this ledger with the keys — not the `retiredKey()` route, which keeps the row. **#10238 is not prejudged**: whether cube authoring is live end to end is still its own measurement — this ledger answers the per-key question only | The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every misleading entry carries `authorWarn` so authors hear about it at compile time diff --git a/packages/spec/liveness/analytics_cube.json b/packages/spec/liveness/analytics_cube.json index a69eb35c18c..368db6aa743 100644 --- a/packages/spec/liveness/analytics_cube.json +++ b/packages/spec/liveness/analytics_cube.json @@ -1,6 +1,6 @@ { "type": "analytics_cube", - "_note": "CubeSchema (packages/spec/src/data/analytics.zod.ts). Seeded 2026-09-17 (#18582): the LAST of the three PENDING_GOVERNANCE debts #18133 declared when PR #18581 widened the governance denominator from the registered kinds to `authorableTypes()`; `sharing_rule` was paid first (PR #18587) and `connector` is paid in the same diff as this file, which empties the map. NOT a registered metadata KIND — it is bound in `UNREGISTERED_KIND_SCHEMAS` (#10194) and reaches this walk through `getMetadataTypeSchema`'s unregistered-kind fallback, so the ledger governs it while `listMetadataTypeSchemaTypes()` still does not enumerate it. THE SHAPE FACT THAT DECIDES EVERY ROW BELOW: one Cube shape, THREE producers, one registry. `packages/services/service-analytics/src/cube-registry.ts` names them itself — (1) authored cubes (`defineStack({ analyticsCubes })` / `defineCube()`), threaded by the CLI into `AnalyticsServiceConfig.cubes` and registered by `registerAll`; (2) COMPILED DATASETS (ADR-0021), where `dataset-compiler.ts` MINTS a Cube from a `dataset` document; (3) ad-hoc query inference (`inferCubeFromQuery`). Only (1) is the authoring door this ledger governs, so a key whose only reader sits on path (2) is NOT live here however busy that reader is — that is the #4837 producer rule applied to a shape with three producers, and it is what decides `dimensions.granularities` and `measures.format` below. Every `live` row therefore carries a `producer` naming the CLI threading site: a consumer citation alone would be the `seed.env` shape, where the mechanism was right and nobody supplied the input. #10238 IS NOT PREJUDGED: the PENDING_GOVERNANCE row this file discharges said whether cube authoring is live end-to-end is its own measurement and 'this row does not prejudge it'. This ledger does not answer that question either — it answers the per-key one (who reads this key?), and the answers below are mixed: the query path (`sql`, `measures.sql`/`.type`, `dimensions.sql`/`.type`, `joins.name`) is genuinely consumed, while the caching, access-control and display-annotation keys are not. PREVIEW READ POINTS ENUMERATED (the #7131 mechanical rule, objectui @dda8f3815): `registerBuiltinPreviews()` in packages/app-shell/src/views/metadata-admin/previews/index.ts registers nineteen types and `analytics_cube` is NOT one of them — this type has no registered metadata-admin preview. Recorded rather than skipped, because 'the type has no registered preview' is the sentence a later sweep needs. What objectui DOES consume is the whole SHAPE: `clientValidation.ts` maps `analytics_cube` to `CubeSchema` itself, and unlike `sharing_rule` it is absent from `AUTHOR_SHAPE_ONLY_TYPES`, so both the CREATE and the EDIT door in metadata-admin refuse a cube this schema rejects. ADR-0054: no row here carries a `proof`, and none is owed — the `analytics` high-risk class binds `dataset/dimensions.dateGranularity` (the dataset door), not this type.", + "_note": "CubeSchema (packages/spec/src/data/analytics.zod.ts). Seeded 2026-09-17 (#18582): the LAST of the three PENDING_GOVERNANCE debts #18133 declared when PR #18581 widened the governance denominator from the registered kinds to `authorableTypes()`; `sharing_rule` was paid first (PR #18587) and `connector` is paid in the same diff as this file, which empties the map. NOT a registered metadata KIND — it is bound in `UNREGISTERED_KIND_SCHEMAS` (#10194) and reaches this walk through `getMetadataTypeSchema`'s unregistered-kind fallback, so the ledger governs it while `listMetadataTypeSchemaTypes()` still does not enumerate it. THE SHAPE FACT THAT DECIDES EVERY ROW BELOW: one Cube shape, THREE producers, one registry. `packages/services/service-analytics/src/cube-registry.ts` names them itself — (1) authored cubes (`defineStack({ analyticsCubes })` / `defineCube()`), threaded by the CLI into `AnalyticsServiceConfig.cubes` and registered by `registerAll`; (2) COMPILED DATASETS (ADR-0021), where `dataset-compiler.ts` MINTS a Cube from a `dataset` document; (3) ad-hoc query inference (`inferCubeFromQuery`). Only (1) is the authoring door this ledger governs, so a key whose only reader sits on path (2) is NOT live here however busy that reader is — that is the #4837 producer rule applied to a shape with three producers, and it is what decides `dimensions.granularities` and `measures.format` below. Every `live` row therefore carries a `producer` naming the CLI threading site: a consumer citation alone would be the `seed.env` shape, where the mechanism was right and nobody supplied the input. #10238 IS NOT PREJUDGED: the PENDING_GOVERNANCE row this file discharges said whether cube authoring is live end-to-end is its own measurement and 'this row does not prejudge it'. This ledger does not answer that question either — it answers the per-key one (who reads this key?), and the answers below are mixed: the query path (`sql`, `measures.sql`/`.type`, `dimensions.sql`/`.type`, `joins.name`) is genuinely consumed, and so is the visibility key `public` (enforced at discovery and at every query door since 2026-09-27 — its row), while the caching and display-annotation keys are not. PREVIEW READ POINTS ENUMERATED (the #7131 mechanical rule, objectui @dda8f3815): `registerBuiltinPreviews()` in packages/app-shell/src/views/metadata-admin/previews/index.ts registers nineteen types and `analytics_cube` is NOT one of them — this type has no registered metadata-admin preview. Recorded rather than skipped, because 'the type has no registered preview' is the sentence a later sweep needs. What objectui DOES consume is the whole SHAPE: `clientValidation.ts` maps `analytics_cube` to `CubeSchema` itself, and unlike `sharing_rule` it is absent from `AUTHOR_SHAPE_ONLY_TYPES`, so both the CREATE and the EDIT door in metadata-admin refuse a cube this schema rejects. ADR-0054: no row here carries a `proof`, and none is owed — the `analytics` high-risk class binds `dataset/dimensions.dateGranularity` (the dataset door), not this type.", "props": { "name": { "status": "live", @@ -134,9 +134,11 @@ } }, "public": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "An ACCESS-CONTROL key that gates nothing, which is the worst class this ledger records (a security no-op / false compliance, the reason the gate exists). Declared `z.boolean().default(false)` under the comment 'Access Control'. Census: three sites WRITE it — `analytics-service.ts#inferCubeFromQuery`, `cube-registry.ts#inferFromObject` and `dataset-compiler.ts`, each a literal `public: false` — and the showcase example authors `public: false`; nothing anywhere READS `cube.public`. Access to `/api/v1/analytics/query` is decided by the REST layer's own auth plus the per-object RLS/tenant scope `applyReadScope` injects from `cube.sql` and `cube.joins[].name`, never by this flag, so `public: true` grants nothing and `public: false` withholds nothing. ⛔ Do NOT read the dead verdict as 'the cube is exposed': it is not a hole that was opened, it is a knob that was never wired. ADR-0049 wants a DECISION here rather than a sweep — either enforce it at the query door or remove it — and the removal half is not free, because a `.default(false)` key is present on every parsed cube." + "status": "live", + "verifiedAt": "2026-09-27", + "evidence": "packages/services/service-analytics/src/cube-visibility.ts#isCubePublic — the one reader: a cube is exposed unless it declares `public: false` (the registry holds the input shape, so an omitted key reads as the schema default, visible); packages/services/service-analytics/src/analytics-service.ts#getMeta filters every cube it serves on `GET /api/v1/analytics/meta` through it, so a hidden cube is omitted and a `?cube=` naming one answers `[]`; packages/services/service-analytics/src/analytics-service.ts#assertCubePublic runs first in `query()` (`POST /api/v1/analytics/query`) and in `generateSql()` (`POST /api/v1/analytics/sql`), and on the dataset door's queries too, which run through the same body asked of the call's own request scope, refusing a hidden cube with packages/services/service-analytics/src/cube-visibility.ts#cubeNotFoundError — the same `CUBE_NOT_FOUND` / 404 refusal, byte for byte, that a name which is neither a cube nor an object gets, so the refusal does not confirm a hidden cube exists — before token resolution, cube inference, admission or any strategy runs. Pinned door by door in packages/services/service-analytics/src/__tests__/cube-public-visibility.test.ts.", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "note": "VISIBILITY on the analytics API — the Cube.dev semantics this schema follows — not row security: an object's records stay governed by its permissions and RLS on every door (`assertReadAdmitted` and the injected read scope), whether or not a cube over it is hidden; what `false` does is leave the cube out of `/analytics/meta` and refuse queries and SQL generation against it; the definition stays readable on the metadata door (below). Dead until 2026-09-27 — declared `z.boolean().default(false)` under an 'Access Control' comment and read by nothing, so `public: false` withheld nothing (false compliance). Enforced in the same change that moved the default to `true`: enforcing the old default as declared would have hidden every authored cube. The three INTERNAL mints (`inferCubeFromQuery`, `compileDataset`, `CubeRegistry.inferFromObject`) wrote the old default `false` as a literal; they now write `true`, because the ad-hoc KPI path and the dataset door both reach their own minted cube through the refusing `query()`. The one in-repo author of `public: false` was `examples/app-showcase`, whose cube IS the app's /analytics/* demonstration — it now omits the key. Not a door: the metadata API (`/api/v1/meta/analytics_cube`) is the model-authoring surface (the Cube.dev schema-file split) and keeps serving every cube definition, hidden ones included, to its authenticated callers; and `@objectstack/driver-memory`'s standalone `MemoryAnalyticsService` does not read the key — no in-repo composition registers it as the `analytics` service, and behind `AnalyticsService` it is reached only through the gated `query()`/`generateSql()`." } } } diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index f52c5835da5..33e4ce3dd0b 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -66,5 +66,5 @@ for both corollaries. | `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 | | `sharing_rule` | 16 | 0 | 0 | 0 | 1 | 17 | | `connector` | 29 | 0 | 0 | 30 | 1 | 60 | -| `analytics_cube` | 17 | 0 | 0 | 10 | 0 | 27 | -| **total** | **939** | **5** | **1** | **149** | **9** | **1103** | +| `analytics_cube` | 18 | 0 | 0 | 9 | 0 | 27 | +| **total** | **940** | **5** | **1** | **148** | **9** | **1103** | diff --git a/packages/spec/scripts/lib/default-changes.ts b/packages/spec/scripts/lib/default-changes.ts index bf12e690054..58b82a043a7 100644 --- a/packages/spec/scripts/lib/default-changes.ts +++ b/packages/spec/scripts/lib/default-changes.ts @@ -554,5 +554,25 @@ export const DEFAULT_CHANGES_BY_MAJOR: Readonly