Skip to content
16 changes: 16 additions & 0 deletions .changeset/25484-build-probe-caller-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/metadata-protocol': patch
'@objectstack/runtime': patch
---

fix: `POST /api/v1/packages/:id/publish-drafts` probes dashboard widgets as the publishing caller, so a correct dataset over a permission-gated object is no longer reported as an error-tier `widget_query_failed`

Clause-②: no
Metadata-protocol: none

The build probes that run after a package publish called the analytics service's `queryDataset` with no execution context. Read admission refuses a read with no context, so every dashboard widget bound to a dataset over a permission-gated object came back as `widget_query_failed` ("Access denied: reading … is not permitted for this user"), with a fix telling the author to change a dataset that renders correctly.

- **`@objectstack/runtime`:** the publish-drafts route now passes the caller's execution context to `publishPackageDrafts` on every request. Before, it passed one only when the request named a `backfill`.
- **`@objectstack/metadata-protocol`:** `publishPackageDrafts` passes that context to `runBuildProbes`, which takes a new optional `context`. The widget probe runs `queryDataset` as that caller, the same path the dashboard renderer takes. "Empty although the object has rows" is now judged over the rows that caller can see.

The probe never falls back to a system context. A publisher who cannot read the dataset still gets the error-tier `widget_query_failed`, because that widget will not render for them either.
69 changes: 69 additions & 0 deletions packages/metadata-protocol/src/build-probes-caller-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#25484] The dashboard-widget plane reads as the caller it is handed.
*
* `queryDataset` receives the caller's context (the renderer's path), and the
* "empty although the object has rows" comparison counts the rows THAT caller
* can see. A system count beside a caller-scoped query would call a row-scoped
* empty answer broken. With no context the plane is unchanged: a context-less
* query, and a system count.
*
* The door-to-admission leg is pinned in `@objectstack/runtime`
* (`packages-publish-drafts-probe-caller-context.test.ts`).
*/

import { describe, expect, it, vi } from 'vitest';
import { runBuildProbes, type ProbeEngine } from './build-probes.js';

const ITEMS: Record<string, unknown> = {
'dashboard board': { name: 'board', widgets: [{ id: 'w1', dataset: 'ds', values: ['total'] }] },
'dataset ds': { name: 'ds', object: 'task_item', measures: [{ name: 'total', aggregate: 'count' }] },
};
const getItem = async (type: string, name: string) => ITEMS[`${type} ${name}`];

const CALLER = { userId: 'u_row_scoped' };

/** Rows exist for the system; the row-scoped caller sees none. Honours `limit`. */
function engineVisibleToSystemOnly(): ProbeEngine & { contexts: unknown[] } {
const contexts: unknown[] = [];
return {
contexts,
find: async (_object: string, query: unknown) => {
const q = query as { limit?: unknown; context?: { isSystem?: boolean } } | undefined;
contexts.push(q?.context);
const rows = q?.context?.isSystem ? [{ id: 'r1' }, { id: 'r2' }] : [];
return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows;
},
};
}

describe('runBuildProbes — the widget plane reads as the caller (#25484)', () => {
it('hands queryDataset the caller context and counts the rows that caller sees', async () => {
const engine = engineVisibleToSystemOnly();
const analytics = { queryDataset: vi.fn(async (..._args: unknown[]) => ({ rows: [] })) };
const report = await runBuildProbes({
engine, getItem, analytics, context: CALLER,
published: [{ type: 'dashboard', name: 'board' }],
});

expect(analytics.queryDataset).toHaveBeenCalledOnce();
expect(analytics.queryDataset.mock.calls[0]![2]).toBe(CALLER);
expect(engine.contexts).toEqual([CALLER]);
expect(report.checked.widgets).toBe(1);
expect(report.issues).toEqual([]);
});

it('with no context: a context-less query and a system count, as before', async () => {
const engine = engineVisibleToSystemOnly();
const analytics = { queryDataset: vi.fn(async (..._args: unknown[]) => ({ rows: [] })) };
const report = await runBuildProbes({
engine, getItem, analytics,
published: [{ type: 'dashboard', name: 'board' }],
});

expect(analytics.queryDataset.mock.calls[0]![2]).toBeUndefined();
expect(engine.contexts).toEqual([{ isSystem: true }]);
expect(report.issues.map((i) => [i.code, i.severity])).toEqual([['empty_query', 'error']]);
});
});
40 changes: 35 additions & 5 deletions packages/metadata-protocol/src/build-probes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,19 @@ export interface RunBuildProbesOptions {
analytics?: ProbeAnalytics;
/** Threaded into engine/analytics reads (tenant scoping). */
organizationId?: string | null;
/**
* The PUBLISHING caller's execution context. Widget probes run
* `queryDataset` as this caller, because that is what the renderer does:
* a widget is healthy when the person who sees it can read it, so the
* probe asks read admission the question the dashboard will ask.
*
* ⛔ Never substitute a system context when it is absent. A probe that
* reads as the system reports a widget healthy that its own publisher
* cannot open, and that is the false clean this plane exists to prevent.
* Absent, the read goes out context-less and read admission answers for
* it — a refusal, reported as such.
*/
context?: unknown;
}

type Rec = Record<string, unknown>;
Expand All @@ -103,17 +116,22 @@ function resultRows(result: unknown): unknown[] {
return [];
}

/** True when the object has at least one row (probe read, isSystem). */
/**
* True when the object has at least one row. A system read by default; the
* widget plane passes the caller's context so "the object has rows" is judged
* over the rows that caller can see — the same rows its dataset query saw.
*/
async function hasRows(
engine: ProbeEngine,
objectName: string,
organizationId?: string | null,
readContext: unknown = { isSystem: true },
): Promise<boolean> {
const rows = await engine.find(objectName, {
fields: ['id'],
limit: 1,
...(organizationId ? { where: { organization_id: organizationId } } : {}),
context: { isSystem: true },
context: readContext,
});
return Array.isArray(rows) && rows.length > 0;
}
Expand All @@ -139,7 +157,7 @@ async function hasRows(
export async function runBuildProbes(opts: RunBuildProbesOptions): Promise<BuildProbeReport> {
const issues: RuntimeBuildIssue[] = [];
const checked = { seeds: 0, views: 0, widgets: 0, objects: 0 };
const { engine, getItem, published, analytics, organizationId } = opts;
const { engine, getItem, published, analytics, organizationId, context } = opts;

// Memoized active-item reads (a dashboard and its widgets share datasets).
const itemCache = new Map<string, unknown | undefined>();
Expand Down Expand Up @@ -340,9 +358,21 @@ export async function runBuildProbes(opts: RunBuildProbesOptions): Promise<Build

const objectName = typeof dataset.object === 'string' ? dataset.object : undefined;
try {
const result = await analytics.queryDataset(dataset, selection, undefined);
// [#25484] As the publishing caller — the renderer's path. With
// no context here, read admission refused every dataset over a
// permission-gated object, and a correct widget was reported as
// an error-tier `widget_query_failed`. A caller who genuinely
// cannot read the dataset is still refused, and still reported.
const result = await analytics.queryDataset(dataset, selection, context);
const rows = resultRows(result);
if (rows.length === 0 && objectName && (await hasRows(engine, objectName, organizationId))) {
// "Empty although the object has rows" is judged over the rows
// THIS caller can see: a system count would call a row-scoped
// empty answer broken.
const rowsContext = context && typeof context === 'object' ? context : undefined;
if (
rows.length === 0 && objectName
&& (await hasRows(engine, objectName, organizationId, rowsContext))
) {
issues.push({
layer: 'runtime',
severity: 'error',
Expand Down
5 changes: 4 additions & 1 deletion packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26260,7 +26260,9 @@ export class ObjectStackProtocolImplementation implements
* [#24253] The caller's execution context: the principal the
* `backfill` writes run as, so object permissions, RLS/FLS and record
* hooks apply exactly as they do to that caller's own edit. Required
* when `backfill` names anything; nothing else here reads it.
* when `backfill` names anything. [#25484] The build probes read as it
* too: a dashboard widget is probed through `queryDataset` as this
* caller, the renderer's path, never as the system.
*/
context?: unknown;
}): Promise<{
Expand Down Expand Up @@ -27144,6 +27146,7 @@ export class ObjectStackProtocolImplementation implements
published,
...(analytics && typeof analytics.queryDataset === 'function' ? { analytics } : {}),
organizationId: null,
...(request.context !== undefined ? { context: request.context } : {}),
});
} catch {
probes = undefined;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#25484] `POST /packages/:id/publish-drafts` probes a published dashboard's
* widgets AS THE PUBLISHING CALLER.
*
* The probe docblock promises "the SAME `queryDataset` path the dashboard
* renderer hits", and the renderer passes the caller's context. The probe
* passed none, so the analytics service's object-level read admission refused
* every dataset over a permission-gated object and a correct widget came back
* as an error-tier `widget_query_failed` ("Access denied: reading … is not
* permitted for this user") telling the author to fix a dataset that was fine.
*
* What is real here: the door (`HttpDispatcher.handlePackages`), the protocol's
* `publishPackageDrafts`, `runBuildProbes`, and an `AnalyticsService` whose
* `admitObjectRead` judges the context it is handed — the gate that refused.
* What is a double: the draft store and the phase-1/phase-2 publish seams
* (the same seams `packages/objectql/src/build-probes.test.ts` stubs), and the
* driver the analytics strategy reads.
*
* The two legs are each other's control: the reader's publish must come back
* clean, and the non-reader's must still carry the refusal, so neither "the
* probe stopped running" nor "the probe now reads as the system" can pass.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DatasetSchema } from '@objectstack/spec/ui';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import { AnalyticsService } from '@objectstack/service-analytics';
import { assertEngineFindOnePredicate } from '@objectstack/metadata-core';
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
import { HttpDispatcher } from '../http-dispatcher.js';

/** The object read admission walls off from everyone but the reader. */
const GATED = 'task_item';

const DATASET = DatasetSchema.parse({
name: 'task_metrics',
label: 'Task metrics',
object: GATED,
dimensions: [],
measures: [{ name: 'task_count', label: 'Tasks', aggregate: 'count' }],
});

const ITEMS: Record<string, unknown> = {
'dashboard task_board': {
name: 'task_board',
widgets: [{ id: 'w_tasks', dataset: 'task_metrics', values: ['task_count'] }],
},
'dataset task_metrics': DATASET,
};

/** Both callers may publish; only the reader may read `task_item`. */
const caller = (userId: string) => ({
request: {},
executionContext: {
userId,
systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
},
}) as any;
const READER = 'u_reader';
const NON_READER = 'u_publisher_without_read';

function makeDoor() {
const admissionContexts: Array<ExecutionContext | undefined> = [];
const analytics = new AnalyticsService({
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
admitObjectRead: (object, context) => {
admissionContexts.push(context);
return object !== GATED || context?.userId === READER;
},
executeAggregate: async () => [{ task_count: 7 }],
});

const protocol = new ObjectStackProtocolImplementation({} as never);
const p = protocol as any;
p.ensureOverlayIndex = async () => {};
p.getOverlayRepo = () => ({
listDrafts: async () => [
{ type: 'dataset', name: 'task_metrics' },
{ type: 'dashboard', name: 'task_board' },
],
get: async (ref: any, opts: any) =>
opts?.state === 'draft' ? { body: ITEMS[`${ref.type} ${ref.name}`], hash: 'h' } : null,
});
vi.spyOn(p, 'promoteDraftForPublish').mockImplementation(async (req: any) => ({
singularType: req.type,
orgId: null,
advisories: [],
result: { version: 'h', seq: 1, item: { body: ITEMS[`${req.type} ${req.name}`] }, packageId: null },
}));
vi.spyOn(p, 'runPublishSideEffects').mockResolvedValue({});
p.getMetaItem = async ({ type, name }: any) => ({ item: ITEMS[`${type} ${name}`] });
p.getMetaItems = async () => ({ items: [] });
const engineFinds: Array<{ object: string; context: unknown }> = [];
p.engine = {
find: async (object: string, query: any) => {
engineFinds.push({ object, context: query?.context });
const rows = [{ id: 'r1' }, { id: 'r2' }];
return typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows;
},
// The ADR-0067 pre-publish capture: never published, so no active row.
findOne: async (object: string, query: unknown) => {
assertEngineFindOnePredicate(object, query as never);
return null;
},
// The ADR-0067 commit row the batch writes; not under test here.
insert: async (_object: string, row: unknown) => row,
};
p.getServicesRegistry = () => new Map<string, unknown>([['analytics', analytics]]);

const kernel: any = {
getService: (name: string) => {
if (name === 'protocol') return Promise.resolve(protocol);
if (name === 'objectql') {
return Promise.resolve({
insert: vi.fn(), find: vi.fn(), update: vi.fn(),
registry: { getAllPackages: vi.fn().mockReturnValue([]) },
});
}
if (name === 'metadata') return Promise.resolve({ getObject: vi.fn() });
return null;
},
context: { getService: () => null, trigger: vi.fn() },
};
return { dispatcher: new HttpDispatcher(kernel), admissionContexts, engineFinds };
}

async function publishAs(userId: string) {
const door = makeDoor();
const result = await door.dispatcher.handlePackages(
'/com.tasks/publish-drafts', 'POST', {}, {}, caller(userId),
);
expect(result.response?.status).toBe(200);
const data: any = (result.response as any)?.body?.data;
expect(data?.outcome).toBe('published');
return { ...door, probes: data?.probes };
}

afterEach(() => vi.restoreAllMocks());

describe('publish-drafts widget probes run as the publishing caller (#25484)', () => {
it('a correct dataset over a gated object the caller CAN read yields no error-tier issue', async () => {
const { probes, admissionContexts, engineFinds } = await publishAs(READER);

expect(probes?.checked?.widgets).toBe(1);
expect(probes.issues.filter((i: any) => i.severity === 'error')).toEqual([]);
// Admission was asked about the caller, never about a system principal.
expect(admissionContexts.length).toBeGreaterThan(0);
for (const ctx of admissionContexts) {
expect(ctx?.userId).toBe(READER);
expect((ctx as any)?.isSystem).toBeUndefined();
}
// No system read was used to judge this widget either.
expect(engineFinds.filter((f) => (f.context as any)?.isSystem)).toEqual([]);
});

it('a caller who CANNOT read the dataset still gets the error-tier widget_query_failed', async () => {
const { probes, admissionContexts } = await publishAs(NON_READER);

expect(probes?.checked?.widgets).toBe(1);
const failed = probes.issues.filter((i: any) => i.code === 'widget_query_failed'); // adr0112-ok: D6c build-probe diagnostics code
expect(failed).toHaveLength(1);
expect(failed[0]).toMatchObject({
layer: 'runtime',
severity: 'error',
artifact: { type: 'dashboard', name: 'task_board' },
ref: { type: 'dataset', name: 'task_metrics', member: 'w_tasks' },
});
expect(admissionContexts.every((ctx) => ctx?.userId === NON_READER)).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -312,12 +312,13 @@ describe('publish-drafts backfill crosses the door (#24253)', () => {
expect(PublishPackageDraftsResponseSchema.parse(data).backfillApplied).toEqual(stopped);
});

it('without the key, the protocol is sent neither a backfill nor a context', async () => {
it('without the key, the protocol is sent no backfill but still THIS caller\'s context (#25484: the build probes read as it)', async () => {
const { publishPackageDrafts } = await publishDrafts({ apps: [] });

const sent = publishPackageDrafts.mock.calls[0]![0];
expect('backfill' in sent).toBe(false);
expect('context' in sent).toBe(false);
expect(sent.context).toEqual(PKG_ADMIN().executionContext);
expect(sent.context?.isSystem).toBeUndefined();
});

it('the protocol\'s request refusal answers 400 VALIDATION_ERROR with its issues, nothing flipped or announced', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -390,8 +390,12 @@ describe('#15068 · 0 · the publish-then-read path really runs', () => {
const { publishRequest } = await publishThenRead({ activeOrganizationId: ORG });

// The door threads no organization into `publishPackageDrafts` (nor
// into `applyPublishedSeeds`), even with one active.
expect(publishRequest()).toEqual({ packageId: PKG });
// into `applyPublishedSeeds`), even with one active. [#25484] The
// caller's execution context does ride along, verbatim: the build
// probes read a widget's dataset AS that caller (the renderer's path),
// and nothing in the publish derives a write scope from it.
const caller = PKG_ADMIN(ORG).executionContext;
expect(publishRequest()).toEqual({ packageId: PKG, context: caller });
expect(publishRequest()).not.toHaveProperty('organizationId');
});
});
Expand Down
Loading
Loading