From 6c5eb0278fbb85b49545bc4f9aa0d16f90bbd744 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 07:53:54 +0000 Subject: [PATCH 1/6] test(service-analytics): pins for the generic-exit declarations at the analytics door Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- .../src/__tests__/api-exposure-door.test.ts | 430 ++++++++++++++++++ 1 file changed, 430 insertions(+) create mode 100644 packages/services/service-analytics/src/__tests__/api-exposure-door.test.ts diff --git a/packages/services/service-analytics/src/__tests__/api-exposure-door.test.ts b/packages/services/service-analytics/src/__tests__/api-exposure-door.test.ts new file mode 100644 index 00000000000..9e05c5d2800 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/api-exposure-door.test.ts @@ -0,0 +1,430 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The analytics door honours the two declarations every other generic exit + * honours: an object's `enable` block (`apiEnabled: false`, and a whitelist + * that does not grant the aggregate verb) and a field's `internal: true`. + * + * ## The two facets + * + * - **Object.** The door asks the spec's one exposure decision, + * `apiExposureDenialReason(enable, 'aggregate')`, for every object a query + * reads — the base object, a declared join, a relationship hop — and refuses + * with the code and status the data door answers for the same declaration: + * `404 OBJECT_API_DISABLED`, `405 OBJECT_API_METHOD_NOT_ALLOWED`. + * - **Field.** A member that reads a field declared `internal: true` — as a + * dimension (the group key), a measure's input, a filter operand, a dataset + * filter or a relationship hop's column — is refused `400 INVALID_FIELD`, + * naming the object and the field. Every analytics member is EVALUATED, never + * projected, so the data door's row-path answer (omit the column) has no + * analytics equivalent; the data door's own aggregate face refuses the same + * field as a group key for the same reason. + * + * ## The shape these cases are written to catch + * + * Both facets bind every caller, so each refusal runs for a member and an + * administrator, each granted read on every object and field (the security + * double below admits everything), and on BOTH strategies: the native-SQL path + * that reads the database with no engine in front of it, and the ObjectQL path. + * Each refusal also proves nothing reached the database for the object, and + * that an ad-hoc refusal leaves the cube registry as it was. + * + * The controls are the ones a lazy fix loses: an ordinary object and an + * ordinary column are served exactly as before, and an object that merely HAS + * an internal field is still served for every other member. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { AnalyticsServicePlugin } from '../plugin.js'; +import { AnalyticsService } from '../analytics-service.js'; + +const HIDDEN = 'exposure_hidden'; +const NO_LIST = 'exposure_nolist'; +const VAULT = 'exposure_vault'; +const LEDGER = 'exposure_ledger'; +const OPEN = 'exposure_open'; + +const text = (name: string, extra: Record = {}) => ({ name, type: 'text' as const, ...extra }); + +/** Declared off: the data door answers 404 OBJECT_API_DISABLED for every verb. */ +const HIDDEN_OBJECT = { + name: HIDDEN, + label: 'Hidden', + enable: { apiEnabled: false, apiMethods: [] }, + fields: { title: text('title'), region: text('region') }, +}; +/** Exposed, but its whitelist grants `get` only: the aggregate verb is not served. */ +const NO_LIST_OBJECT = { + name: NO_LIST, + label: 'No list', + enable: { apiMethods: ['get'] }, + fields: { title: text('title'), region: text('region') }, +}; +/** Exposed, with one column declared `internal: true`. */ +const VAULT_OBJECT = { + name: VAULT, + label: 'Vault', + fields: { title: text('title'), region: text('region'), digest: text('digest', { internal: true }) }, +}; +/** Exposed, with lookups into the vault and into the hidden object (the relationship-hop cases). */ +const LEDGER_OBJECT = { + name: LEDGER, + label: 'Ledger', + fields: { + title: text('title'), + vault_ref: { name: 'vault_ref', type: 'lookup' as const, reference: VAULT }, + hidden_ref: { name: 'hidden_ref', type: 'lookup' as const, reference: HIDDEN }, + }, +}; +/** The ordinary control. */ +const OPEN_OBJECT = { + name: OPEN, + label: 'Open', + fields: { title: text('title'), region: text('region') }, +}; + +const ROWS: Record>> = { + [HIDDEN]: [ + { id: 'h1', title: 'a', region: 'west' }, + { id: 'h2', title: 'b', region: 'east' }, + ], + [NO_LIST]: [{ id: 'n1', title: 'a', region: 'west' }], + [VAULT]: [ + { id: 'v1', title: 'a', region: 'west', digest: 'stored-one' }, + { id: 'v2', title: 'b', region: 'west', digest: 'stored-two' }, + { id: 'v3', title: 'c', region: 'east', digest: 'stored-three' }, + ], + [LEDGER]: [ + { id: 'l1', title: 'a', vault_ref: 'v1', hidden_ref: 'h1' }, + { id: 'l2', title: 'b', vault_ref: 'v3', hidden_ref: 'h2' }, + ], + [OPEN]: [ + { id: 'o1', title: 'a', region: 'west' }, + { id: 'o2', title: 'b', region: 'west' }, + { id: 'o3', title: 'c', region: 'east' }, + ], +}; + +/** Configured cubes — the authored cube door over each subject. */ +const CUBES = [ + { + name: 'hidden_cube', + title: 'Hidden cube', + sql: HIDDEN, + public: true, + measures: { rows: { label: 'Rows', type: 'count' as const, sql: '*' } }, + dimensions: { region: { label: 'Region', type: 'string' as const, sql: 'region' } }, + }, + { + name: 'nolist_cube', + title: 'No-list cube', + sql: NO_LIST, + public: true, + measures: { rows: { label: 'Rows', type: 'count' as const, sql: '*' } }, + dimensions: {}, + }, + { + name: 'vault_cube', + title: 'Vault cube', + sql: VAULT, + public: true, + measures: { rows: { label: 'Rows', type: 'count' as const, sql: '*' } }, + dimensions: { + region: { label: 'Region', type: 'string' as const, sql: 'region' }, + sealed: { label: 'Sealed', type: 'string' as const, sql: 'digest' }, + }, + }, +]; + +const dataset = (name: string, object: string, extra: Record = {}) => + DatasetSchema.parse({ + name, + label: name, + object, + dimensions: [], + measures: [{ name: 'row_count', label: 'Rows', aggregate: 'count' }], + ...extra, + }); + +const quiet: any = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; + +/** Grants every caller read on every object and every field, the internal one included. */ +const grantEverything = (engine: ObjectQL) => { + const fieldsOf = async (object: string) => Object.keys(engine.registry.getObject(object)?.fields ?? {}); + return { + canReadObject: async () => true, + getReadableFields: fieldsOf, + getQueryableFields: fieldsOf, + getReadFilter: async () => undefined, + }; +}; + +const PERSONAS: ReadonlyArray<{ label: string; context: ExecutionContext }> = [ + { label: 'a member granted read', context: { userId: 'u_member', permissions: ['member_default'] } as ExecutionContext }, + { + label: 'an administrator', + context: { + userId: 'u_admin', + permissions: ['admin_full_access'], + systemPermissions: ['view_all_data', 'modify_all_data', 'manage_platform_settings'], + } as ExecutionContext, + }, +]; + +const STRATEGIES = [ + { label: 'native SQL', capabilities: undefined }, + { + label: 'ObjectQL', + capabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + }, +] as const; + +interface Refusal extends Error { + code?: string; + status?: number; + object?: string; + field?: string; +} + +/** The error a call rejected with — and a loud failure if it resolved. */ +async function rejection(call: () => unknown): Promise { + let resolved: unknown; + try { + resolved = await call(); + } catch (e) { + return e as Refusal; + } + throw new Error(`expected a refusal, got ${JSON.stringify(resolved)}`); +} + +const pick = (e: Refusal) => ({ code: e.code, status: e.status, object: e.object, ...(e.field ? { field: e.field } : {}) }); + +describe.each(STRATEGIES)('the analytics door honours the generic-exit declarations — $label strategy', ({ capabilities }) => { + let engine: ObjectQL; + let service: AnalyticsService; + /** Every database read, by object, whichever strategy issued it. */ + const reads = new Map(); + const readsOf = (object: string) => reads.get(object) ?? 0; + const snapshot = () => new Map(reads); + const readSince = (before: Map, object: string) => readsOf(object) - (before.get(object) ?? 0); + + beforeAll(async () => { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any); + engine = new ObjectQL({ logger: quiet } as any); + engine.registerDriver(driver, true); + await engine.init(); + for (const obj of [HIDDEN_OBJECT, NO_LIST_OBJECT, VAULT_OBJECT, LEDGER_OBJECT, OPEN_OBJECT]) { + engine.registry.registerObject(obj as any); + } + await engine.syncSchemas(); + for (const [object, rows] of Object.entries(ROWS)) { + for (const row of rows) await engine.insert(object, { ...row } as any); + } + + const realExecute = (engine as any).execute.bind(engine); + (engine as any).execute = (sql: unknown, opts?: { object?: string }) => { + const object = opts?.object; + if (object) reads.set(object, readsOf(object) + 1); + return realExecute(sql, opts); + }; + const realAggregate = engine.aggregate.bind(engine); + (engine as any).aggregate = (object: string, ...rest: unknown[]) => { + reads.set(object, readsOf(object) + 1); + return (realAggregate as any)(object, ...rest); + }; + + const security = grantEverything(engine); + const registered: Record = {}; + 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 () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + describe.each(PERSONAS)('as $label', ({ context }) => { + // ── The object facet ──────────────────────────────────────────────── + it('an `apiEnabled: false` object is refused 404 OBJECT_API_DISABLED on every door, and nothing reads it', async () => { + const before = snapshot(); + const calls: Array<[string, () => unknown]> = [ + ['ad-hoc query', () => service.query({ cube: HIDDEN, measures: ['count'] }, context)], + ['ad-hoc query, grouped', () => service.query({ cube: HIDDEN, measures: ['count'], dimensions: ['region'] }, context)], + ['SQL face', () => service.generateSql({ cube: HIDDEN, measures: ['count'] }, context)], + ['configured cube', () => service.query({ cube: 'hidden_cube', measures: ['rows'], dimensions: ['region'] }, context)], + ['dataset door', () => service.queryDataset(dataset('hidden_rows', HIDDEN), { measures: ['row_count'] }, context)], + ['relationship hop', () => service.query({ cube: LEDGER, measures: ['count'], dimensions: ['hidden_ref.region'] }, context)], + ]; + for (const [door, call] of calls) { + expect(pick(await rejection(call)), door).toEqual({ code: 'OBJECT_API_DISABLED', status: 404, object: HIDDEN }); + } + expect(readSince(before, HIDDEN), 'no read of the hidden object').toBe(0); + expect(readSince(before, LEDGER), 'no read of the hop\'s base object either').toBe(0); + }); + + it('an object whose whitelist does not grant the aggregate verb is refused 405 OBJECT_API_METHOD_NOT_ALLOWED', async () => { + const before = snapshot(); + const calls: Array<[string, () => unknown]> = [ + ['ad-hoc query', () => service.query({ cube: NO_LIST, measures: ['count'] }, context)], + ['SQL face', () => service.generateSql({ cube: NO_LIST, measures: ['count'] }, context)], + ['configured cube', () => service.query({ cube: 'nolist_cube', measures: ['rows'] }, context)], + ['dataset door', () => service.queryDataset(dataset('nolist_rows', NO_LIST), { measures: ['row_count'] }, context)], + ]; + for (const [door, call] of calls) { + const err = await rejection(call); + expect(pick(err), door).toEqual({ code: 'OBJECT_API_METHOD_NOT_ALLOWED', status: 405, object: NO_LIST }); + } + expect(readSince(before, NO_LIST), 'no read of the object').toBe(0); + }); + + it('an ad-hoc refusal mints nothing: no cube is inferred, and the registry is as it was', async () => { + const metaBefore = await service.getMeta(); + const infer = vi.spyOn(service as any, 'inferCubeFromQuery'); + try { + await rejection(() => service.query({ cube: HIDDEN, measures: ['count'] }, context)); + await rejection(() => service.generateSql({ cube: HIDDEN, measures: ['count'] }, context)); + expect(infer).not.toHaveBeenCalled(); + } finally { + infer.mockRestore(); + } + expect(await service.getMeta()).toEqual(metaBefore); + }); + + // ── The field facet ───────────────────────────────────────────────── + it('an `internal: true` field is refused 400 INVALID_FIELD in every member position, and nothing reads the object', async () => { + const before = snapshot(); + const calls: Array<[string, () => unknown]> = [ + ['dimension', () => service.query({ cube: VAULT, measures: ['count'], dimensions: ['digest'] }, context)], + ['measure input', () => service.query({ cube: VAULT, measures: ['digest_count_distinct'] }, context)], + ['filter operand', () => service.query({ cube: VAULT, measures: ['count'], where: { digest: 'stored-one' } }, context)], + ['SQL face', () => service.generateSql({ cube: VAULT, measures: ['count'], dimensions: ['digest'] }, context)], + ['configured cube dimension', () => service.query({ cube: 'vault_cube', measures: ['rows'], dimensions: ['sealed'] }, context)], + ['configured cube filter', () => service.query({ cube: 'vault_cube', measures: ['rows'], where: { sealed: 'stored-one' } }, context)], + [ + 'dataset dimension', + () => service.queryDataset( + dataset('vault_sealed', VAULT, { dimensions: [{ name: 'sealed', label: 'Sealed', field: 'digest', type: 'string' }] }), + { measures: ['row_count'], dimensions: ['sealed'] }, + context, + ), + ], + [ + 'dataset filter', + () => service.queryDataset(dataset('vault_filtered', VAULT, { filter: { digest: 'stored-one' } }), { measures: ['row_count'] }, context), + ], + ]; + for (const [position, call] of calls) { + expect(pick(await rejection(call)), position).toEqual({ code: 'INVALID_FIELD', status: 400, object: VAULT, field: 'digest' }); + } + expect(readSince(before, VAULT), 'no read of the object').toBe(0); + }); + + it('an `internal: true` field reached through a relationship hop is refused, and nothing reads either object', async () => { + const before = snapshot(); + for (const query of [ + { cube: LEDGER, measures: ['count'], dimensions: ['vault_ref.digest'] }, + { cube: LEDGER, measures: ['count'], where: { 'vault_ref.digest': 'stored-one' } }, + ]) { + expect(pick(await rejection(() => service.query(query, context))), JSON.stringify(query)).toEqual({ + code: 'INVALID_FIELD', status: 400, object: VAULT, field: 'digest', + }); + } + expect(readSince(before, LEDGER)).toBe(0); + expect(readSince(before, VAULT)).toBe(0); + }); + + // ── Controls ──────────────────────────────────────────────────────── + it('CONTROL: an ordinary object and an ordinary column are served as before', async () => { + const groups = async (cube: string, measure: string, member: string, object = OPEN) => { + const result = await service.query({ cube, measures: [measure], dimensions: [member] }, context); + return (result.rows as Array>) + .map((r) => [String(r[member]), Number(r[measure])] as const) + .sort(([a], [b]) => a.localeCompare(b)); + }; + expect(await groups(OPEN, 'count', 'region')).toEqual([['east', 1], ['west', 2]]); + const answered = await service.queryDataset(dataset('open_rows', OPEN), { measures: ['row_count'] }, context); + expect(Number((answered.rows as Array>)[0].row_count)).toBe(3); + const sql = await service.generateSql({ cube: OPEN, measures: ['count'] }, context); + expect(sql.sql).toContain(OPEN); + }); + + it('CONTROL: an object that HAS an internal field is still served for every other member', async () => { + const groups = async (cube: string, measure: string, member: string) => { + const result = await service.query({ cube, measures: [measure], dimensions: [member] }, context); + return (result.rows as Array>) + .map((r) => [String(r[member]), Number(r[measure])] as const) + .sort(([a], [b]) => a.localeCompare(b)); + }; + expect(await groups(VAULT, 'count', 'region')).toEqual([['east', 1], ['west', 2]]); + expect(await groups('vault_cube', 'rows', 'region')).toEqual([['east', 1], ['west', 2]]); + const counted = await service.query({ cube: VAULT, measures: ['count'], where: { region: 'west' } }, context); + expect(Number((counted.rows as Array>)[0].count)).toBe(2); + const hop = await service.query({ cube: LEDGER, measures: ['count'], dimensions: ['vault_ref.region'] }, context); + expect( + (hop.rows as Array>) + .map((r) => [String(r['vault_ref.region']), Number(r.count)]) + .sort(([a], [b]) => String(a).localeCompare(String(b))), + ).toEqual([['east', 1], ['west', 1]]); + }); + }); +}); + +describe('the exposure gate fails closed', () => { + it('a declaration lookup that throws refuses the query PERMISSION_DENIED / 403, and nothing runs', async () => { + const executed: string[] = []; + const service = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (object) => { + executed.push(object); + return [{ count: 1 }]; + }, + getObjectDeclaration: () => { + throw new Error('registry unavailable'); + }, + }); + for (const call of [ + () => service.query({ cube: OPEN, measures: ['count'] }), + () => service.generateSql({ cube: OPEN, measures: ['count'] }), + ]) { + expect(pick(await rejection(call))).toEqual({ code: 'PERMISSION_DENIED', status: 403, object: OPEN }); + } + expect(executed).toEqual([]); + }); + + it('the plugin refuses when no data engine can answer for the declaration', async () => { + const executed: string[] = []; + const registered: Record = {}; + await new AnalyticsServicePlugin({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (object) => { + executed.push(object); + return [{ count: 1 }]; + }, + admitObjectRead: () => true, + }).init({ + getService: (name: string) => registered[name], + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, + hook: () => {}, + logger: quiet, + } as never); + const service = registered.analytics as AnalyticsService; + expect(pick(await rejection(() => service.query({ cube: OPEN, measures: ['count'] })))).toEqual({ + code: 'PERMISSION_DENIED', status: 403, object: OPEN, + }); + expect(executed).toEqual([]); + }); +}); From 160b2200db7c7d204c0f05fe853c17e391d91206 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 07:57:41 +0000 Subject: [PATCH 2/6] fix(service-analytics): the analytics door judges the generic-exit declarations every other door judges An object's enable block is asked through the spec's one exposure decision for the aggregate operation, and a member reading a field declared internal: true is refused in every position, for every caller, ahead of strategy selection. Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- .../src/analytics-service.ts | 119 +++++++++ .../src/api-exposure-door.ts | 252 ++++++++++++++++++ .../services/service-analytics/src/plugin.ts | 18 ++ 3 files changed, 389 insertions(+) create mode 100644 packages/services/service-analytics/src/api-exposure-door.ts diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index ef0103d0396..721d122eac4 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -61,6 +61,13 @@ import { // body column of `sys_metadata` / `sys_metadata_history` as a member is refused // here, on the one family seam, before any strategy runs. import { storedMetadataBodyAnalyticsRefusal } from './stored-metadata-body-refusal.js'; +// The generic-exit declarations — `enable` (ADR-0049) and a field's `internal` +// flag — asked at this door as every other door asks them. +import { + assertNoInternalFieldNamed, + assertObjectsExposed, + type ObjectDeclarationProvider, +} from './api-exposure-door.js'; // [#15768] The measure result-type rule — which aggregates return a value of // the aggregated field's own type, and which are numeric whatever they read. // Owned in its own module so the enumerated verdict per `AggregationFunction` @@ -1046,6 +1053,22 @@ export interface AnalyticsServiceConfig { * exist" has ONE answer across `/data` and `/analytics`. */ getObjectFieldNames?: (objectName: string) => readonly string[] | undefined; + /** + * What `objectName` declares — its registered definition's `enable` block + * and field map — or `undefined` when it declares nothing (not registered). + * + * Consulted by the generic-exit gates (`api-exposure-door.ts`): every object + * a query reads is judged by the spec's one exposure decision + * (`apiExposureDenialReason`, ADR-0049) for the `aggregate` operation, and a + * member that reads a field declared `internal: true` is refused in every + * position — for every caller, administrators included. + * + * Access-narrowing, so a throw REFUSES the query (fail-closed). Absence means + * no gate, reported once at the first query: that is a host constructing + * this service by hand. The production bridge in `plugin.ts` always wires it, + * from the same schema registry `/data` reads. + */ + getObjectDeclaration?: ObjectDeclarationProvider; /** * ADR-0021 — optional object-graph resolver used when compiling datasets: * `(baseObject, relationshipName) => relatedObjectName | undefined`. When @@ -1383,6 +1406,10 @@ export class AnalyticsService implements IAnalyticsService { private readonly getObjectDatasource?: AnalyticsServiceConfig['getObjectDatasource']; /** ADR-0062 D6 — federated-object probe (strategy routing + #5115's gate). */ private readonly isExternalObject?: AnalyticsServiceConfig['isExternalObject']; + /** The generic-exit declaration probe (`api-exposure-door.ts`). */ + private readonly objectDeclarationProvider?: ObjectDeclarationProvider; + /** One-shot flag for the no-declaration-probe stand-down warning. */ + private warnedNoObjectDeclaration = false; /** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */ private warnedNoObjectRegistry = false; /** [#19995] One-shot flag for the {@link reportUnjudgedReadScope} warning. */ @@ -1439,6 +1466,7 @@ export class AnalyticsService implements IAnalyticsService { this.draftRowsResolver = config.draftRowsResolver; this.isRegisteredObject = config.isRegisteredObject; this.getObjectFieldNames = config.getObjectFieldNames; + this.objectDeclarationProvider = config.getObjectDeclaration; this.getObjectDatasource = config.getObjectDatasource; this.isExternalObject = config.isExternalObject; // [#8286] Resolved ONCE, at construction, from the host's explicit choice @@ -1703,6 +1731,10 @@ export class AnalyticsService implements IAnalyticsService { // [#20933] The set is derived ONCE here and handed to both questions, so // the objects admitted and the objects scoped are one value, not two calls. const objects = this.queryObjects(query, scope); + // The generic-exit gate's object half over the FULL set — relationship + // hops included, which only the resolved cube can name — ahead of the read + // admission, in the data door's order: exposure first, permission second. + this.assertQueryObjectsExposed(objects); await this.assertReadAdmitted(objects, context); // [#21120] …and the stored-metadata-body refusal — a hard product refusal, // not a permission check, so it runs ahead of (and independent of) the @@ -1716,6 +1748,14 @@ export class AnalyticsService implements IAnalyticsService { query.cube ? scope.getCube(query.cube) : undefined, query.cube ? reads.getDatasetScope(query.cube) : undefined, ); + // …and the generic-exit gate's field half, beside it for the same reason: + // a field declared `internal: true` is refused in every member position, + // for every caller, whether or not a security provider is wired. + this.assertNoInternalFieldsQueried( + query, + query.cube ? scope.getCube(query.cube) : undefined, + query.cube ? reads.getDatasetScope(query.cube) : undefined, + ); // [#20917] …and the FIELD-level gate, right behind it and for the same // reason: every member the query names is judged here, once, so every // strategy — and the SQL echo — inherits the verdict by construction. The @@ -1916,6 +1956,66 @@ export class AnalyticsService implements IAnalyticsService { await assertObjectsReadable(objects, provider, context, this.logger); } + /** + * The OBJECT half of the generic-exit gate (`api-exposure-door.ts`): every + * object in `objects` must be exposed for the aggregate operation by the + * spec's one decision, or the query is refused with the data door's code — + * `404 OBJECT_API_DISABLED` / `405 OBJECT_API_METHOD_NOT_ALLOWED`. + * + * Asked twice per call, on purpose. Once at each door's entry, over the + * objects the request names before any cube is inferred or compiled — so a + * refused request mints nothing, not even in its own request scope — and once + * in {@link callCtx} over the full {@link queryObjects} set (relationship + * hops included), ahead of the read admission: the data door judges the + * exposure declaration before the permission check, and so does this one. + */ + private assertQueryObjectsExposed(objects: Iterable): void { + const provider = this.objectDeclarationProvider; + if (!provider) { + if (!this.warnedNoObjectDeclaration) { + this.warnedNoObjectDeclaration = true; + this.logger.warn( + '[Analytics] no getObjectDeclaration hook configured — the generic-exit gates (an object\'s ' + + '`enable.apiEnabled` / `apiMethods`, a field\'s `internal: true`) are INACTIVE for this service. ' + + 'AnalyticsServicePlugin wires the hook from the data engine; a host constructing AnalyticsService ' + + 'by hand supplies getObjectDeclaration. Reported once.', + ); + } + return; + } + assertObjectsExposed(objects, provider, this.logger); + } + + /** + * The FIELD half of the generic-exit gate: a member that reads a field its + * object declares `internal: true` — a dimension, a measure's input, a + * filter or sort key, a dataset filter, a relationship hop's column — is + * refused `400 INVALID_FIELD`. The members are resolved by + * {@link namedQueryFields}, the resolver the field gate and the strategies + * share, so the gate and the statement can never disagree about which field + * a member reads. + */ + private assertNoInternalFieldsQueried( + query: AnalyticsQuery, + cube: Cube | undefined, + datasetScope: DatasetScope | undefined, + ): void { + const provider = this.objectDeclarationProvider; + if (!provider || !cube) return; + assertNoInternalFieldNamed(namedQueryFields(query, cube, datasetScope, this.hopReference), provider, this.logger); + } + + /** + * The objects a request names before any cube is inferred or compiled: an + * authored cube's base object and declared joins, or — for a name no cube + * answers — the name itself, which is the object the ad-hoc path would infer + * a cube over. + */ + private namedCubeObjects(name: string, scope: CubeScope): Set { + const cube = scope.getCube(name); + return cube ? this.cubeObjects(cube) : new Set([name]); + } + /** * [#20917] The FIELD-LEVEL read gate, asked at this door for every field the * query names, BEFORE a strategy is selected — `NativeSQLStrategy` compiles @@ -2218,6 +2318,10 @@ export class AnalyticsService implements IAnalyticsService { // 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); + // The generic-exit gate's object half, before any cube is inferred: an + // `apiEnabled: false` object answers the data door's 404 here, and the + // request mints nothing. + this.assertQueryObjectsExposed(this.namedCubeObjects(queryInput.cube, scope)); // [#12230] Expand `{current_user_id}` / date-macro placeholders at THIS // seam — before strategy selection — so every strategy compiles the same @@ -2448,6 +2552,9 @@ export class AnalyticsService implements IAnalyticsService { // column reference is refused ahead of compile and the draft-preview branch, // so no such expression reaches a strategy (`PERMISSION_DENIED` / 403, the one // judge, every tier). See {@link assertDatasetFieldsJudgeable}. + // The generic-exit gate's object half, on the dataset's base object, before + // it is compiled and before the draft-preview branch reads seed rows. + this.assertQueryObjectsExposed([dataset.object]); this.assertDatasetFieldsJudgeable(dataset, context); const compiled = this.compile(dataset); this.logger.debug(`[Analytics] queryDataset "${dataset.name}" (object=${dataset.object}, include=${(dataset.include ?? []).join(',') || '—'})`); @@ -2473,6 +2580,7 @@ export class AnalyticsService implements IAnalyticsService { // second implementation — drafted seed rows are still this object's // rows, and a caller who may not read the object may not read its // pending seed either. + this.assertQueryObjectsExposed(this.cubeObjects(compiled.cube)); await this.assertReadAdmitted(this.cubeObjects(compiled.cube), context); this.logger.debug(`[Analytics] queryDataset "${dataset.name}" → preview over ${seedRows.length} drafted seed row(s)`); // [#20917] …and the field-level gate, per query the executor issues, @@ -2488,6 +2596,13 @@ export class AnalyticsService implements IAnalyticsService { compiled.cube, { filter: compiled.filter, measureFilters: compiled.measureFilters }, ); + // …and the generic-exit gate's field half: a drafted seed row + // carries the same `internal` fields the published ones do. + this.assertNoInternalFieldsQueried( + q, + compiled.cube, + { filter: compiled.filter, measureFilters: compiled.measureFilters }, + ); await this.assertFieldsReadable( q, compiled.cube, @@ -3050,6 +3165,10 @@ export class AnalyticsService implements IAnalyticsService { // [#20381] Same request scope as `query()`: nothing minted here reaches the // shared registry, admitted or refused. const scope = this.requestScope(); + // Same generic-exit gate as `query()`, before any cube is inferred: the + // dry-run door must not compile a statement over an object the data door + // does not expose. + this.assertQueryObjectsExposed(this.namedCubeObjects(queryInput.cube, scope)); // …and the same declared default bucket, so the dry run shows the // statement `query()` would run rather than an unbucketed one. const query = withDeclaredGranularityDefaults( diff --git a/packages/services/service-analytics/src/api-exposure-door.ts b/packages/services/service-analytics/src/api-exposure-door.ts new file mode 100644 index 00000000000..674ffde5467 --- /dev/null +++ b/packages/services/service-analytics/src/api-exposure-door.ts @@ -0,0 +1,252 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The two GENERIC-EXIT declarations, asked at the analytics door: an object's + * `enable` block (ADR-0049 — `apiEnabled: false`, and an `apiMethods` + * whitelist) and a field's `internal: true` flag (ADR-0100's third credential + * channel). + * + * ## Why the door owed them + * + * Every other generic exit already judges both. The REST data routes, the + * runtime dispatcher and the MCP data tools ask the spec's one exposure + * decision, {@link apiExposureDenialReason}, and the cross-object search reads + * the same off switch; the data door omits an `internal` column from every row + * it returns, the write responses strip it, and the engine's `aggregate()` + * refuses it as a group key or an aggregate input. The analytics door read + * neither declaration: an ad-hoc query over an `apiEnabled: false` object + * answered its rows, and an `internal` column was served as a group key on the + * native-SQL path, to a member holding read and to an administrator alike. + * + * ## One decision, not a second rule + * + * The object facet holds no rule of its own. It asks + * {@link apiExposureDenialReason} for the `aggregate` operation — the canonical + * operation an analytics read is (`DATA_ACTION_TO_API_OPERATION.aggregate`, + * derived from `list`), the one the dispatcher gates its own aggregate action + * under — and turns the answer into this door's envelope, the way every other + * door does: `404 OBJECT_API_DISABLED` for the off switch and + * `405 OBJECT_API_METHOD_NOT_ALLOWED` for a whitelist that does not grant the + * verb, the codes the data door answers for the same declaration. Both answers + * of the decision are honoured; reading only the off switch would be a second + * rule (an analytics-only reading of the whitelist as unrestricted). + * + * The field facet holds no rule either: which fields are `internal` is + * `@objectstack/core`'s `collectInternalWriteResponseFields`, the collector + * every write mouth and the knowledge index already ask (strict `=== true`, + * the engine's own spelling). + * + * ## Refused, not withheld — the precedent, measured + * + * On the data door an `internal` column is OMITTED from a row (a `select` + * naming it included), because a row is a projection and a projection can + * leave a column out. The data door's aggregate face refuses the same column + * as a group key — the engine's `rejectCredentialAggregation` — because a group + * key cannot be withheld without changing what the groups count. Every member + * an analytics query names is EVALUATED, never projected: a dimension is a + * group key, a measure aggregates its field, a filter or sort key evaluates + * the value row by row (an oracle that confirms a guessed value with no value + * ever returned). So there is nothing to omit, and every position is refused, + * the refusal-based posture this door already takes for a member it will not + * evaluate (`stored-metadata-body-refusal.ts`, in the same envelope: + * `400 INVALID_FIELD`, naming the object and the field). + * + * ## Every caller, every strategy, before anything reads + * + * Both declarations are about the EXIT, not the caller: they take no user and + * no context, so an administrator is refused exactly as a member is, and there + * is no system carve-out. Both are asked at the service door ahead of strategy + * selection, over the same object set the read admission and the row scope are + * asked over (base object, declared joins, relationship hops), so the native-SQL + * strategy — which reads the database with no engine in front of it — and the + * ObjectQL strategy give one answer by construction. + * + * ## Fail direction + * + * The provider is access-NARROWING, so it fails CLOSED: a lookup that throws + * refuses the query (`403 PERMISSION_DENIED`, logged at `error`), and the + * plugin's bridge throws when no data engine can answer. An object the + * provider answers no declaration for (`undefined`) declares nothing, and the + * spec decision answers "served" for it — the same default every door reads. + * A host that constructs `AnalyticsService` with no provider gets no gate and + * is told so once; `AnalyticsServicePlugin` always wires one. + */ + +import { + apiExposureDenialReason, + effectiveOperationsArray, + resolveEffectiveApiMethods, + type EnableLike, +} from '@objectstack/spec/data'; +import type { RegisteredErrorCode, StandardErrorCode } from '@objectstack/spec/api'; +import { collectInternalWriteResponseFields } from '@objectstack/core'; +import type { NamedField, NamedRead } from './field-read-admission.js'; + +/** + * The slice of an object's registered definition the door reads: its `enable` + * block and its field map. The registry's `getObject()` shape, unparsed. + */ +export interface ObjectDeclaration { + enable?: unknown; + fields?: unknown; +} + +/** + * The host's answer to "what does `objectName` declare?" — `getObject()` on the + * data engine in the shipped composition. + * + * `undefined` / `null` means the object declares nothing (it is not + * registered); a throw refuses the query (fail-closed). + */ +export type ObjectDeclarationProvider = (objectName: string) => ObjectDeclaration | null | undefined; + +/** The canonical operation an analytics read is judged as. */ +const ANALYTICS_OPERATION = 'aggregate'; + +/** + * The data door's two exposure codes, the same pair the REST data routes and + * the MCP data tools answer. Typed against the ledger so a misspelling fails + * `tsc`. + */ +const OBJECT_API_DISABLED_CODE: RegisteredErrorCode = 'OBJECT_API_DISABLED'; +const OBJECT_API_METHOD_NOT_ALLOWED_CODE: RegisteredErrorCode = 'OBJECT_API_METHOD_NOT_ALLOWED'; + +/** The member refusal every sibling refusal at this door answers. */ +const INVALID_FIELD: StandardErrorCode = 'INVALID_FIELD'; + +/** The fail-closed refusal, the read admission's code. */ +const PERMISSION_DENIED: StandardErrorCode = 'PERMISSION_DENIED'; + +type DoorRefusal = Error & { + code?: string; + status?: number; + object?: string; + field?: string; + allowed?: string[]; +}; + +/** Log sink — the subset of `Logger` this module uses (see `read-admission.ts`). */ +interface ExposureLogger { + error?(message: string, error?: Error): void; + warn(message: string): void; +} + +/** `404 OBJECT_API_DISABLED` — the data door's words for the off switch. */ +function objectApiDisabledError(object: string): Error { + const err = new Error(`Object '${object}' is not exposed via the API`) as DoorRefusal; + err.code = OBJECT_API_DISABLED_CODE; + err.status = 404; + err.object = object; + return err; +} + +/** `405 OBJECT_API_METHOD_NOT_ALLOWED`, with the effective operation set. */ +function objectApiMethodNotAllowedError(object: string, allowed: string[]): Error { + const err = new Error( + `API operation '${ANALYTICS_OPERATION}' is not allowed on object '${object}'`, + ) as DoorRefusal; + err.code = OBJECT_API_METHOD_NOT_ALLOWED_CODE; + err.status = 405; + err.object = object; + err.allowed = allowed; + return err; +} + +/** `403 PERMISSION_DENIED` — the declaration could not be read, so nothing runs. */ +function exposureUnresolvedError(object: string): Error { + const err = new Error( + `[Analytics] Access denied: the API exposure declaration for "${object}" could not be resolved, ` + + 'so the query was not run.', + ) as DoorRefusal; + err.code = PERMISSION_DENIED; + err.status = 403; + err.object = object; + return err; +} + +/** + * `400 INVALID_FIELD` for a member that reads an `internal: true` field. Names + * the object and the field — the field the member resolved to — and no value. + */ +function internalFieldError(object: string, field: string): Error { + const err = new Error( + `Cannot query '${object}' by '${field}': the query was not run. The ${field} field is declared ` + + '`internal: true`, so its value is never returned on a generic data exit; an analytics member is ' + + 'evaluated rather than projected, so grouping by it would serve the value as a group key, ' + + 'aggregating it would summarise the value, and filtering or sorting by it would confirm a guessed ' + + 'value. Group, aggregate, filter or sort by another field instead.', + ) as DoorRefusal; + err.code = INVALID_FIELD; + err.status = 400; + err.object = object; + err.field = field; + return err; +} + +/** Read one declaration, refusing (fail-closed) when the provider throws. */ +function declarationOf( + object: string, + provider: ObjectDeclarationProvider, + logger: ExposureLogger | undefined, +): ObjectDeclaration | undefined { + try { + return provider(object) ?? undefined; + } catch (e) { + const cause = e instanceof Error ? e : new Error(String(e)); + const report = + `[Analytics] the API exposure declaration of "${object}" could not be resolved — ` + + 'denying the query (fail-closed)'; + if (logger?.error) logger.error(report, cause); + else logger?.warn(`${report}: ${cause.message}`); + throw exposureUnresolvedError(object); + } +} + +/** + * Refuse the query unless every object it reads is exposed for the aggregate + * operation, by the spec's one decision. The first refused object, in the + * set's order, is the one named. + */ +export function assertObjectsExposed( + objects: Iterable, + provider: ObjectDeclarationProvider, + logger?: ExposureLogger, +): void { + for (const object of objects) { + const enable = declarationOf(object, provider, logger)?.enable as EnableLike | null | undefined; + const reason = apiExposureDenialReason(enable, ANALYTICS_OPERATION); + if (reason === null) continue; + if (reason === 'api-disabled') throw objectApiDisabledError(object); + throw objectApiMethodNotAllowedError(object, effectiveOperationsArray(resolveEffectiveApiMethods(enable!))); + } +} + +/** + * Refuse the query when any member it names reads a field its object declares + * `internal: true` — in every position, every role. `named` is what the door's + * member resolver attributes to each object (relationship hops included); a + * member that names no field is the field gate's to refuse, not this one's. + */ +export function assertNoInternalFieldNamed( + named: readonly NamedRead[], + provider: ObjectDeclarationProvider, + logger?: ExposureLogger, +): void { + const internalByObject = new Map>(); + for (const read of named) { + if ('expression' in read) continue; + const { object, field } = read as NamedField; + let internal = internalByObject.get(object); + if (!internal) { + internal = new Set(collectInternalWriteResponseFields(declarationOf(object, provider, logger))); + internalByObject.set(object, internal); + } + if (internal.has(field)) { + logger?.warn( + `[Analytics] refused a query naming the internal field "${field}" of "${object}" — ` + + 'internal fields are never evaluated by an analytics member', + ); + throw internalFieldError(object, field); + } + } +} diff --git a/packages/services/service-analytics/src/plugin.ts b/packages/services/service-analytics/src/plugin.ts index ccecf120ec1..529668b7862 100644 --- a/packages/services/service-analytics/src/plugin.ts +++ b/packages/services/service-analytics/src/plugin.ts @@ -1498,6 +1498,24 @@ export class AnalyticsServicePlugin implements Plugin { const names = Object.keys(fields); return names.length > 0 ? names : undefined; }, + // The generic-exit declarations (`api-exposure-door.ts`): the object's + // `enable` block and field map, read from the SAME schema registry the + // data door's exposure gate and its `internal` strip read, so "is this + // object exposed, is this field internal" has one answer across `/data` + // and `/analytics`. Access-narrowing, so it fails CLOSED: unlike the two + // existence probes above, an engine that cannot answer is a THROW (the + // query is refused), never "nothing declared" — `ObjectQLPlugin`'s engine, + // registered as "data", is the one place the declarations live. + getObjectDeclaration: (objectName: string) => { + const engine = dataEngine(); + if (!engine) { + throw new Error( + 'no "data" engine with getObject() is registered, so the object\'s exposure declaration cannot ' + + 'be read. Register ObjectQLPlugin\'s engine as "data".', + ); + } + return engine.getObject?.(objectName) as { enable?: unknown; fields?: unknown } | undefined; + }, draftRowsResolver, }; From a7c40fbb2da347159e2af47295fd7386b45d6428 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:01:54 +0000 Subject: [PATCH 3/6] test(service-analytics): drop an unused helper parameter from the exposure pins Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- .../service-analytics/src/__tests__/api-exposure-door.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/services/service-analytics/src/__tests__/api-exposure-door.test.ts b/packages/services/service-analytics/src/__tests__/api-exposure-door.test.ts index 9e05c5d2800..4e8f12305f3 100644 --- a/packages/services/service-analytics/src/__tests__/api-exposure-door.test.ts +++ b/packages/services/service-analytics/src/__tests__/api-exposure-door.test.ts @@ -348,7 +348,7 @@ describe.each(STRATEGIES)('the analytics door honours the generic-exit declarati // ── Controls ──────────────────────────────────────────────────────── it('CONTROL: an ordinary object and an ordinary column are served as before', async () => { - const groups = async (cube: string, measure: string, member: string, object = OPEN) => { + const groups = async (cube: string, measure: string, member: string) => { const result = await service.query({ cube, measures: [measure], dimensions: [member] }, context); return (result.rows as Array>) .map((r) => [String(r[member]), Number(r[measure])] as const) From b0453d11396db495080b3ba2c5027412dc819791 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:05:49 +0000 Subject: [PATCH 4/6] chore(changeset): the analytics door judges the generic-exit declarations Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- .changeset/22634-analytics-exposure-gate.md | 33 +++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .changeset/22634-analytics-exposure-gate.md diff --git a/.changeset/22634-analytics-exposure-gate.md b/.changeset/22634-analytics-exposure-gate.md new file mode 100644 index 00000000000..03885dc138d --- /dev/null +++ b/.changeset/22634-analytics-exposure-gate.md @@ -0,0 +1,33 @@ +--- +'@objectstack/service-analytics': minor +--- + +fix(service-analytics)!: the analytics door judges the two generic-exit declarations every other door judges — an object's `enable` block and a field's `internal: true` (#22634) + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +Every generic exit judges an object's `enable` block through the spec's one exposure decision, `apiExposureDenialReason`: the REST data routes, the runtime dispatcher, the MCP data tools, and the cross-object search for the off switch. Every generic exit also withholds a field declared `internal: true`: the data door omits it from rows, the write responses strip it, and the engine's `aggregate()` refuses it as a group key. The analytics door read neither declaration. + +**FROM.** On both strategies (native SQL and ObjectQL), and for a member holding read and an administrator alike: + +- The ad-hoc query, the SQL face, the configured-cube door and the dataset door served the rows of an object declaring `apiEnabled: false`, and of an object whose `apiMethods` whitelist does not grant `list`. +- On the native-SQL strategy, a field declared `internal: true` was served as a group key, as an aggregate input and as a filter operand. +- On the ObjectQL strategy, the engine refused the same field as a group key or an aggregate input with an undeclared `500`. It still admitted the field as a filter operand. + +**TO.** Ahead of strategy selection, the door asks the spec's decision for the `aggregate` operation over every object a query reads: the base object, declared joins and relationship hops. It answers with the data door's codes for the same declaration: + +- `404 OBJECT_API_DISABLED` for `apiEnabled: false`; +- `405 OBJECT_API_METHOD_NOT_ALLOWED` for a whitelist that does not grant the verb. + +On the ad-hoc doors this happens before any cube is inferred, so a refused request mints nothing. A member that reads a field declared `internal: true` is refused `400 INVALID_FIELD`, naming the object and the field. This holds in every position: dimension, measure input, filter or sort key, dataset filter, and relationship hop. + +- **Refused, not withheld.** On the data door, the row path omits such a field because a row is a projection. Every analytics member is evaluated instead: a group key, an aggregate input, a predicate. There is nothing to omit, and the data door's own aggregate face already refuses the field as a group key. +- **Every caller.** Neither declaration takes a user, so administrators are refused exactly as members are. There is no system carve-out. +- **Fails closed.** A declaration lookup that throws refuses the query with `403 PERMISSION_DENIED`, logged at `error`. `AnalyticsServicePlugin` wires the lookup from the data engine and refuses when no engine can answer. A host constructing `AnalyticsService` by hand with no `getObjectDeclaration` hook gets no gate, and is told so once. +- **Unchanged.** An ordinary object and an ordinary field are served as before. An object that merely has an `internal` field is still served for every other field, and so is a bare count over it. + +**Measured producers.** On `origin/main` `f368b7e980`, no authored cube, dataset, dashboard or example in this repository names an `apiEnabled: false` object, an object whose whitelist omits `list`, or an `internal` field. The platform's System Overview datasets count `sys_user`, `sys_organization` and `sys_session`, and group `sys_audit_log` by two ordinary fields. Each of those objects grants `list`, and none of those members is `internal`. Deployed and cloud-held analytics definitions were NOT MEASURED. From 55415a5aa99fe40cf6d04d887ae025038c3cf716 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:38:28 +0000 Subject: [PATCH 5/6] fix(spec): record service-analytics as an emitter of the two API-exposure codes in the error-code ledger Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- packages/spec/src/api/error-code-ledger.zod.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 44f58d59262..0a7c18eb073 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1118,6 +1118,12 @@ export const ERROR_CODE_LEDGER = { '@objectstack/service-analytics': [ 'CUBE_NOT_FOUND', 'DATASET_INVALID', // [#5367] dataset/selection refusal raised by `dataset-refusal.ts` + // [#22634] The generic-exit gate (`api-exposure-door.ts`) stamps the data + // door's two exposure codes. Measured on the wire: `/analytics/query` and + // `/analytics/sql` through the runtime dispatcher, `/analytics/dataset/query` + // through rest. `@objectstack/rest` keeps its own rows for the data door. + 'OBJECT_API_DISABLED', + 'OBJECT_API_METHOD_NOT_ALLOWED', 'RAW_SQL_UNSUPPORTED', 'READ_SCOPE_COMPILE_FAILED', // [#5367] RLS read-scope lowering failed fail-closed — a SERVER fault (500), never the caller's ], From 8ae8b87cdcfb77f24dad99e243ed1d256072020f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:50:18 +0000 Subject: [PATCH 6/6] chore(changeset): spec patch for the service-analytics ledger row Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude --- .changeset/22634-spec-ledger-analytics-exposure-codes.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/22634-spec-ledger-analytics-exposure-codes.md diff --git a/.changeset/22634-spec-ledger-analytics-exposure-codes.md b/.changeset/22634-spec-ledger-analytics-exposure-codes.md new file mode 100644 index 00000000000..ce8eaa36c4c --- /dev/null +++ b/.changeset/22634-spec-ledger-analytics-exposure-codes.md @@ -0,0 +1,7 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): the error-code ledger records `@objectstack/service-analytics` as an emitter of `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` (#22634) + +The analytics door now answers the data door's two exposure codes for an object whose `enable` block refuses the aggregate operation, so `ERROR_CODE_LEDGER['@objectstack/service-analytics']` lists both. That is the provenance `check:error-code-provenance` holds every emitting package to. Value-only: `REGISTERED_ERROR_CODES` is unchanged, because both codes were already registered under `@objectstack/rest`. No export, schema or generated artifact moves.