Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/22663-analytics-unexposed-cube-registration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@objectstack/service-analytics': minor
---

fix(service-analytics)!: a configured cube, or a dataset `registerDataset` registers, over an object the API does not serve is refused at registration, and `GET /analytics/meta` no longer lists it (#22663)

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the analytics registry's accept set: a cube or dataset whose base object or declared join the spec's exposure decision already denies is no longer registered, and every query of such a cube was already refused. The remedy is to point the definition at an object the API serves or remove it, never a rewrite of anyone's code or metadata. `CubeRegistry` gains one optional constructor parameter, which is additive; nothing on the type surface narrows. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no exported declaration is removed or narrowed (not runtime-interface-only or type-surface-only). -->

**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.

Since the analytics door started judging an object's `enable` block, every query over an object whose `enable` the spec's `apiExposureDenialReason` denies for the `aggregate` operation is refused: `404 OBJECT_API_DISABLED` for `apiEnabled: false`, `405 OBJECT_API_METHOD_NOT_ALLOWED` for an `apiMethods` whitelist that does not grant it. The registration doors did not ask the same question.

**FROM.** A configured cube (`analyticsCubes`, or `AnalyticsServicePlugin({ cubes })`), or a dataset registered through `registerDataset` (or the service's `datasets` option), whose base object or declared join is such an object registered silently. `GET /analytics/meta` listed it, and every query of it then answered 404 or 405. Nothing told the author at registration time.

**TO.** Registration asks the same decision, for the same operation, over the same objects the query door's first check reads: the base object and the declared joins.

- **A configured cube** over such an object is not registered. The service logs one warning per cube that names the cube, the object and the declaration (`enable.apiEnabled: false`, or the `enable.apiMethods` whitelist and the operations it grants). Every other cube still registers, and the boot continues. This is the channel a pre-registered dataset that cannot compile already takes.
- **`registerDataset`** throws, registering nothing. The error names the dataset, the object and the declaration, and carries the query door's code and status for that declaration (`OBJECT_API_DISABLED` / 404 or `OBJECT_API_METHOD_NOT_ALLOWED` / 405) with the object.
- **A cube written straight to the service's public `cubeRegistry`** is refused by the registry's own admission in the same words.
- **`GET /analytics/meta`** never lists a refused definition.

**Unchanged.**

- A cube or dataset over an exposed object registers and lists exactly as before.
- A host that wires no `getObjectDeclaration` hook registers every definition as before. `AnalyticsServicePlugin` always wires it.
- Registration judges only what is known when it runs, and does not block otherwise. An object that is not registered yet when the analytics plugin's `init()` registers the configured cubes is not refused, and neither is one whose lookup throws. Objects declared by a plugin that inits later, or in a `start()`, fall in this class. Such a cube registers and lists as before, and the query door still refuses every query of it.
- A relationship hop that one member walks is judged at query time, per member, and does not refuse the whole cube.

**Measured producers.** On `origin/main` `ee3ae0360d`, this repository has one authored cube, `showcase_delivery` (`showcase_task` joined to `showcase_project`), and no `registerDataset` producer outside tests. Neither object declares an `enable` block. The eleven example and platform datasets (six in `examples/**`, five in `platform-objects`) are queried through the request-scoped dataset door and never enter the registry. Deployed and cloud-held definitions were NOT MEASURED.
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,10 @@ const ROWS: Record<string, Array<Record<string, unknown>>> = {
],
};

/** Configured cubes — the authored cube door over each subject. */
/**
* Configured cubes — the authored cube door over each subject, registered
* before the subjects are declared (see `beforeAll`).
*/
const CUBES = [
{
name: 'hidden_cube',
Expand Down Expand Up @@ -217,6 +220,28 @@ describe.each(STRATEGIES)('the analytics door honours the generic-exit declarati
engine = new ObjectQL({ logger: quiet } as any);
engine.registerDriver(driver, true);
await engine.init();

// The plugin inits BEFORE the subjects are declared, on purpose: a
// configured cube over an object the API does not serve is refused at
// registration when the declaration is already known
// (`unexposed-definition-registration.test.ts`), so the configured-cube
// rows below can only reach the QUERY face through the window registration
// cannot judge — an object registered after the analytics plugin's init.
// That window is what they pin.
const security = grantEverything(engine);
const registered: Record<string, unknown> = {};
await new AnalyticsServicePlugin({
cubes: CUBES as any,
...(capabilities ? { queryCapabilities: capabilities } : {}),
}).init({
getService: (name: string) => (name === 'data' ? engine : name === 'security' ? security : registered[name]),
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
hook: () => {},
logger: quiet,
} as never);
service = registered.analytics as AnalyticsService;

for (const obj of [HIDDEN_OBJECT, NO_LIST_OBJECT, VAULT_OBJECT, LEDGER_OBJECT, OPEN_OBJECT]) {
engine.registry.registerObject(obj as any);
}
Expand All @@ -236,20 +261,6 @@ describe.each(STRATEGIES)('the analytics door honours the generic-exit declarati
reads.set(object, readsOf(object) + 1);
return (realAggregate as any)(object, ...rest);
};

const security = grantEverything(engine);
const registered: Record<string, unknown> = {};
await new AnalyticsServicePlugin({
cubes: CUBES as any,
...(capabilities ? { queryCapabilities: capabilities } : {}),
}).init({
getService: (name: string) => (name === 'data' ? engine : name === 'security' ? security : registered[name]),
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
hook: () => {},
logger: quiet,
} as never);
service = registered.analytics as AnalyticsService;
});

afterAll(async () => {
Expand Down
Loading
Loading