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
16 changes: 16 additions & 0 deletions .changeset/28508-me-permissions-has-any-queue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/plugin-sharing': patch
'@objectstack/plugin-hono-server': patch
---

feat(plugin-hono-server,plugin-sharing): `GET /auth/me/permissions` answers `hasAnyQueue`, whether the caller's organization has at least one ownership queue

Clause-②: yes
Metadata-protocol: none

The console's list toolbar offers an "in a queue" toggle, and it needs to know, once per session, whether that toggle can ever return a row. A plain member cannot find out: `member_default` names no `sys_queue` entry, so `GET /data/sys_queue` answers 403. The server now answers the one question for them.

- **The field.** Every authenticated `/auth/me/permissions` body carries `hasAnyQueue: boolean`. It is `true` when the caller's organization has at least one ownership queue: a declared queue the activation ledger has not switched off, or a `sys_queue` row of the organization. A row under a declared queue's name does not count, since that name resolves nowhere. Only the boolean is disclosed: no names and no counts. An anonymous caller still gets `{ "authenticated": false }` and nothing else.
- **Where it is resolved.** `OwnershipQueueService.hasAnyQueue(organizationId)` in `@objectstack/plugin-sharing`, under system context, with the same registry-first read and activation-ledger filter `queuesOf` uses.
- **When there is no answer.** No ownership-queue service registered, a service without the method, or a service that throws answers `false`. A throw is logged at `warn` and does not cost the caller the rest of the permissions body. The closed body the handler answers on a fault carries `false` too.
- **Unchanged.** A member's read of `sys_queue` and `sys_queue_member` stays refused. No route is added.
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,8 @@ describe('[#15747] the current-user faces assemble through the shared assembler'
fields: { 'showcase_order.total': { readable: true, editable: true } },
systemPermissions: ['showcase.export_data'],
tabPermissions: { exports: 'visible' },
// [#28508] No ownership-queue service in this mount: `false`.
hasAnyQueue: false,
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #28508: `/auth/me/permissions` carries `hasAnyQueue`, one boolean. It says
// whether the caller's organization has at least one ownership queue, declared
// or `sys_queue`. The console's list toolbar reads it once per session to decide
// whether its "in a queue" toggle can ever return a row. The member's own read
// of `sys_queue` stays refused (the #5491 baseline), so the server answers the
// question for them, and only the boolean is disclosed.
//
// The handler asks the ownership-queue service (`OWNERSHIP_QUEUE_SERVICE`), the
// one place queue resolution and the activation ledger live. It passes the
// caller's organization and asks nothing else. These cases pin the handler's
// half of the contract:
//
// - the service's answer is carried as it is, for the caller's organization;
// - no service registered, or a service without the method, answers `false`;
// - a service that throws answers `false` and logs, rather than failing the
// whole permissions body;
// - the key is on every authenticated body (the degraded branch with no
// SecurityPlugin, and the closed body of the outer catch);
// - an anonymous caller gets only `{ authenticated: false }`.
//
// The service's own answer (declared, ledger-switched, `sys_queue` rows) is
// pinned in plugin-sharing's `ownership-queue.test.ts`, and the whole path
// through the real HTTP door in `@objectstack/verify`'s
// `me-permissions-has-any-queue.test.ts`.
//
// Same mount shape as `current-user-endpoints-outer-catch-closed.test.ts`: a
// bare Hono app, the real registrar, and a locator that THROWS for an
// unregistered slot, as the kernel's does.

import { describe, it, expect, vi } from 'vitest';
import { Hono } from 'hono';
import { OWNERSHIP_QUEUE_SERVICE } from '@objectstack/spec/contracts';
import { registerCurrentUserEndpoints } from './current-user-endpoints';

const ME_PERMISSIONS = '/api/v1/auth/me/permissions';
const USER = 'usr_member';
const ACTIVE_ORG = 'org_active';

type Row = Record<string, any>;

/** The session's organization membership: what the resolver reads to stamp `tenantId`. */
const TABLES: Record<string, Row[]> = {
sys_user: [{ id: USER, email: 'member@example.com' }],
sys_member: [{ user_id: USER, organization_id: ACTIVE_ORG, role: 'member' }],
};

/** Scalar equality only; any combinator is refused rather than misread. */
function matches(row: Row, where: Row | undefined): boolean {
return Object.entries(where ?? {}).every(([key, cond]) => {
if (key.startsWith('$') || (cond !== null && typeof cond === 'object')) {
throw new Error(`fake driver: unsupported predicate on ${key}`);
}
return (row[key] ?? null) === (cond ?? null);
});
}

const ql = {
find: async (object: string, opts: any) => {
const rows = (TABLES[object] ?? []).filter((r) => matches(r, opts?.where));
return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows;
},
registry: { getAllApps: () => [], getAllObjects: () => [] },
getSchema: () => undefined,
};

type QueueSlot =
| { kind: 'absent' }
| { kind: 'no-method' }
| { kind: 'answers'; value: boolean }
| { kind: 'throws' };

function mount(
queue: QueueSlot,
{ security = true, signedIn = true, mergeThrows = false }: { security?: boolean; signedIn?: boolean; mergeThrows?: boolean } = {},
) {
const hasAnyQueue = vi.fn(async (_organizationId: string | null) => {
if (queue.kind === 'throws') throw new Error('sys_queue read failed');
return queue.kind === 'answers' ? queue.value : false;
});
const services: Record<string, unknown> = {
auth: {
api: {
getSession: async () => (signedIn
? { user: { id: USER, email: 'member@example.com' }, session: { activeOrganizationId: ACTIVE_ORG } }
: null),
},
},
objectql: ql,
metadata: { list: async () => [] as unknown[] },
};
if (security) {
services.security = {
resolvePermissionSetsForContext: async () => (mergeThrows
// The one input `buildEffectiveObjectPermissions` documents it
// throws on: it lands the handler in its outer catch.
? [{ name: 'member_default', objects: { x: null } }]
: [{ name: 'member_default', systemPermissions: [], objects: {}, fields: {} }]),
};
}
if (queue.kind === 'no-method') services[OWNERSHIP_QUEUE_SERVICE] = { queuesOf: async () => [] };
else if (queue.kind !== 'absent') services[OWNERSHIP_QUEUE_SERVICE] = { hasAnyQueue };
const warn = vi.fn();
const app = new Hono();
registerCurrentUserEndpoints({
rawApp: app,
ctx: {
logger: { debug() {}, warn },
getService: <T,>(name: string): T => {
if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`);
return services[name] as T;
},
},
});
return { app, warn, hasAnyQueue };
}

async function read(app: Hono) {
const res = await app.request(`http://localhost${ME_PERMISSIONS}`);
expect(res.status).toBe(200);
return res.json();
}

describe('[#28508] /auth/me/permissions carries hasAnyQueue from the ownership-queue service', () => {
it.each([true, false])('the service answering %s for the caller\'s organization is carried as it is', async (value) => {
const { app, hasAnyQueue } = mount({ kind: 'answers', value });
const body = await read(app);
expect(body.hasAnyQueue).toBe(value);
// Asked once, for the caller's organization and nothing else.
expect(hasAnyQueue).toHaveBeenCalledTimes(1);
expect(hasAnyQueue).toHaveBeenCalledWith(ACTIVE_ORG);
});

it('no ownership-queue service registered answers false: with no service, no record can sit in a queue', async () => {
const { app, warn } = mount({ kind: 'absent' });
const body = await read(app);
expect(body.hasAnyQueue).toBe(false);
// The ordinary state of a stack without plugin-sharing: not a fault.
expect(warn).not.toHaveBeenCalled();
// The rest of the body is untouched by the absence.
expect(body.systemPermissions).toEqual([]);
expect(body.permissionSets).toEqual(['member_default']);
});

it('a registered service that does not carry the method answers false', async () => {
const body = await read(mount({ kind: 'no-method' }).app);
expect(body.hasAnyQueue).toBe(false);
});

it('a service that throws answers false and logs, and the permissions body still answers in full', async () => {
const { app, warn } = mount({ kind: 'throws' });
const body = await read(app);
expect(body.hasAnyQueue).toBe(false);
expect(body.permissionSets).toEqual(['member_default']);
expect(Array.isArray(body.systemPermissions)).toBe(true);
const lines = warn.mock.calls.map(([line]) => line);
expect(lines).toContain('[hono] /auth/me/permissions: hasAnyQueue could not be resolved — answering false');
expect(lines).not.toContain('[hono] /auth/me/permissions failed');
});

it('the degraded branch (no SecurityPlugin) carries the key too: the question does not depend on it', async () => {
const body = await read(mount({ kind: 'answers', value: true }, { security: false }).app);
expect(body.hasAnyQueue).toBe(true);
// Still the degraded body: capabilities unknown.
expect('systemPermissions' in body).toBe(false);
});

it('the closed body of the outer catch carries the key, as false: it vouches for nothing it did not finish', async () => {
const { app, warn } = mount({ kind: 'answers', value: true }, { mergeThrows: true });
const body = await read(app);
expect(body.systemPermissions).toEqual([]);
expect(body.hasAnyQueue).toBe(false);
expect(warn.mock.calls.map(([line]) => line)).toContain('[hono] /auth/me/permissions failed');
});

it('an anonymous caller is told only that it is anonymous, and the service is not asked', async () => {
const { app, hasAnyQueue } = mount({ kind: 'answers', value: true }, { signedIn: false });
const body = await read(app);
expect(body).toEqual({ authenticated: false });
expect(hasAnyQueue).not.toHaveBeenCalled();
});

it('the value on the wire is a boolean and nothing else: no names, no counts', async () => {
const body = await read(mount({ kind: 'answers', value: true }).app);
expect(typeof body.hasAnyQueue).toBe('boolean');
const queueish = Object.keys(body).filter((k) => /queue/i.test(k));
expect(queueish).toEqual(['hasAnyQueue']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,9 @@ describe('[#23047] a throw inside the try, after the caller is known, answers th
fields: {},
systemPermissions: [],
tabPermissions: {},
// [#28508] On the closed body too, as `false`: no ownership-queue
// service is registered here, and a fault vouches for nothing.
hasAnyQueue: false,
});
// The load-bearing key, stated on its own: present-and-empty is "no
// capabilities"; absent is "unknown", which the console fails open on.
Expand Down Expand Up @@ -226,7 +229,10 @@ describe('[#23047] controls — what the fix must not move', () => {

expect(await res.text()).toBe(
'{"authenticated":true,"userId":"usr_member","tenantId":"org_active",'
+ '"positions":["org_member","everyone"],"permissionSets":[],"objects":{},"fields":{}}',
+ '"positions":["org_member","everyone"],"permissionSets":[],"objects":{},"fields":{},'
// [#28508] The degraded branch carries the queue boolean too; no
// ownership-queue service is registered in this mount, so `false`.
+ '"hasAnyQueue":false}',
);
expect(failedCalls(warn)).toHaveLength(0);
});
Expand Down
78 changes: 71 additions & 7 deletions packages/plugins/plugin-hono-server/src/current-user-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,12 @@ import {
} from '@objectstack/core';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import { preferredLocaleFromHeader } from '@objectstack/spec/system';
import type {
IMetadataService,
IObjectQLEngine,
ISecurityService,
Logger,
import {
OWNERSHIP_QUEUE_SERVICE,
type IMetadataService,
type IObjectQLEngine,
type ISecurityService,
type Logger,
} from '@objectstack/spec/contracts';
import { allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability';
import { ifNoneMatchHits, representationEtag, REVALIDATE_PRIVATE_CACHE_CONTROL } from '@objectstack/types';
Expand Down Expand Up @@ -275,7 +276,7 @@ type MePermissionsTabVisibility = 'visible' | 'hidden' | 'default_on' | 'default

/**
* The grant half of the `/auth/me/permissions` body on a security-wired stack —
* every key besides the caller's identity. All six are REQUIRED, so a caller of
* every key besides the caller's identity. All seven are REQUIRED, so a caller of
* {@link mePermissionsBody} cannot drop one without tsc refusing it.
*/
interface MePermissionsGrants {
Expand All @@ -285,6 +286,8 @@ interface MePermissionsGrants {
fields: Record<string, { readable: boolean; editable: boolean }>;
systemPermissions: string[];
tabPermissions: Record<string, MePermissionsTabVisibility>;
/** [#28508] See {@link organizationHasAnyQueue}. */
hasAnyQueue: boolean;
}

/**
Expand All @@ -298,7 +301,61 @@ interface MePermissionsGrants {
* Fresh values per call, so no response aliases another.
*/
function closedMePermissionsGrants(): MePermissionsGrants {
return { positions: [], permissionSets: [], objects: {}, fields: {}, systemPermissions: [], tabPermissions: {} };
return {
positions: [], permissionSets: [], objects: {}, fields: {}, systemPermissions: [], tabPermissions: {},
hasAnyQueue: false,
};
}

/**
* [#28508] The ownership-queue service's one "any queue at all?" answer, as
* this handler reads it. Not yet on the spec contract
* (`IOwnershipQueueService`), which gains it with this payload's response
* schema; until then the method is optional here, like the contract's own
* `declaredQueueNamed`.
*/
interface AnyQueueReader {
hasAnyQueue?: (organizationId: string | null) => Promise<boolean>;
}

/**
* [#28508] Does the caller's organization have at least one ownership queue,
* declared (and not switched off in the activation ledger) or a `sys_queue`
* row? The console's list toolbar reads this once per session to decide
* whether its "in a queue" toggle can ever return a row. A member may not read
* `sys_queue` (the #5491 baseline stays), so the server answers for them, and
* the boolean is all it discloses: no names, no counts.
*
* The answer is the ownership-queue service's, which resolves under system
* context with the registry-first rule and the ledger in one place. This
* function only reaches it:
*
* - no service registered (no plugin-sharing) ⇒ `false`: with no service, no
* record can sit in a queue, so the toggle has nothing to find;
* - a service without the method ⇒ `false`, for the same reason;
* - the service throws ⇒ `false`, logged. A queue fault hides one toolbar
* toggle; it must not cost the caller the whole permissions body.
*
* It never throws.
*/
async function organizationHasAnyQueue(
ctx: CurrentUserEndpointsContext,
organizationId: string | null,
): Promise<boolean> {
try {
const service = (() => {
try { return ctx.getService<AnyQueueReader>(OWNERSHIP_QUEUE_SERVICE) ?? null; }
catch { return null; }
})();
const hasAnyQueue = service?.hasAnyQueue;
if (typeof hasAnyQueue !== 'function') return false;
return (await hasAnyQueue.call(service, organizationId)) === true;
} catch (err: any) {
ctx.logger?.warn?.('[hono] /auth/me/permissions: hasAnyQueue could not be resolved — answering false', {
err: err?.message,
});
return false;
}
}

/**
Expand Down Expand Up @@ -327,6 +384,7 @@ function mePermissionsBody(
fields: grants.fields,
systemPermissions: grants.systemPermissions,
tabPermissions: grants.tabPermissions,
hasAnyQueue: grants.hasAnyQueue,
};
}

Expand Down Expand Up @@ -871,6 +929,10 @@ export function registerCurrentUserEndpoints(
// own), but a stack with no metadata service degraded here before
// and must keep degrading here, or this becomes a second behaviour
// change riding on the delegation.
// [#28508] Asked on every authenticated branch, the degraded one
// included: whether the organization has a queue does not depend on
// SecurityPlugin. Never throws (see {@link organizationHasAnyQueue}).
const hasAnyQueue = await organizationHasAnyQueue(ctx, identity.tenantId);
if (!resolvePermissionSets || !metadata) {
// Auth resolved but security plugin isn't wired — emit
// an empty-but-authenticated body so the frontend can
Expand All @@ -884,6 +946,7 @@ export function registerCurrentUserEndpoints(
permissionSets: execCtx.permissions ?? [],
objects: {},
fields: {},
hasAnyQueue,
});
}
// [#7616] The caller's sets, resolved ONCE by the owner of the
Expand Down Expand Up @@ -956,6 +1019,7 @@ export function registerCurrentUserEndpoints(
fields,
systemPermissions: Array.from(systemPermissions),
tabPermissions,
hasAnyQueue,
}) });
} catch (err: any) {
ctx.logger?.warn?.('[hono] /auth/me/permissions failed', { err: err?.message });
Expand Down
Loading
Loading