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
6 changes: 6 additions & 0 deletions src/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ import {
CatalogApplyToPeopleAction,
CatalogSyncAction,
} from './catalog.actions.js';
import { MemberOpenAction } from './member.actions.js';
import { TaskCompleteAction, TaskSkipAction, TaskUndoAction } from './task.actions.js';

export { CatalogApplyAction, CatalogApplyToPeopleAction, CatalogSyncAction };
export { TaskCompleteAction, TaskSkipAction, TaskUndoAction };
export { MemberOpenAction };

export const dulyActions = [
CatalogApplyAction,
Expand All @@ -40,4 +42,8 @@ export const dulyActions = [
TaskCompleteAction,
TaskUndoAction,
TaskSkipAction,
// Object-bound to `sys_user`, a platform object: defineStack() merges it
// into the served sys_user actions. A url action, so no handler. It is the
// stopgap that reaches duly_member (objectstack#28979); see member.actions.ts.
MemberOpenAction,
];
69 changes: 69 additions & 0 deletions src/actions/member.actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { defineAction } from '@objectstack/spec';

/**
* `duly_member_open` — the way to reach the member page. ⚠️ A STOPGAP, filed
* upstream as objectstack-ai/objectstack#28979 and built to be deleted.
*
* ── Why a person does not open on `duly_member` by itself ────────────────
* `duly_member` (`src/pages/member.page.ts`) is a record page over `sys_user`.
* plugin-auth ships `sys_user_detail` for the same object with
* `isDefault: true`, and from 18 on the console opens the `isDefault` page
* first. Measured on 18.2.0 against a live `pnpm demo`: Duly > People > a
* person opens plugin-auth's admin page, and `duly_member` renders only on
* the explicit route `/apps/<app>/sys_user/record/<id>/page/duly_member`.
* Nothing on `PageSchema` lets an app outrank another package's default, and
* a second default refuses the boot (`DEFAULT_RECORD_PAGE_CONFLICT`). The
* page's header, gap §5, has the whole measurement.
*
* ── What this does instead ────────────────────────────────────────────────
* It is a `type: 'url'` action on `sys_user` that navigates to that explicit
* route, with `${ctx.recordId}` as the person in context. Measured on 18.2.0:
* the action renders in the record's "⋯" menu (the header shows three
* buttons and folds the rest). Clicking it lands on the member page with
* every region rendered. That is one click more than the page should cost,
* and it is not pretended otherwise.
*
* **`record_header` only, not `list_item`.** On the People row menu it
* renders, but 18.2's console runs a row `url` action with no record in
* context. `${ctx.recordId}`, `${ctx.record.id}` and `${ctx.selection.ids}`
* all interpolate to empty there, measured live, so the click lands on
* `/sys_user/record/page/duly_member`, a broken route. A menu item that leads
* nowhere is worse than none, so the row location is left out until the
* console fills the row's record in.
*
* No `openIn`: an in-app path navigates in place by default, which is what a
* record-to-record hop should do.
*
* The path carries the app's route segment, the stack's package id
* (`manifest.id` in `objectstack.config.ts`). The spec declares no
* app-relative form and no `${ctx.*}` member that names the current app, so
* the segment is written out. `test/member-page.test.ts` pins it against the
* manifest and the page name.
*
* ── Ungated, deliberately, including in Setup > Users ─────────────────────
* Actions belong to the object, not to the app, so this also shows in Setup's
* user record and opens into Duly from there. It carries no
* `requiredPermissions`: it writes nothing, and the page it opens is read-only
* and row-scoped by the permission sets on what it lists. (Seat ruling on
* duly#113.)
*
* ── When to delete it ─────────────────────────────────────────────────────
* When objectstack#28979 lands a way for an app to say which record page its
* own routes open for `sys_user`, declare that, delete this file and its
* barrel entry, and turn the resolution pins in `test/member-page.test.ts`
* around.
*/
export const MEMBER_OPEN_ACTION = 'duly_member_open';

export const MemberOpenAction = defineAction({
name: MEMBER_OPEN_ACTION,
objectName: 'sys_user',
label: 'Open member page',
description: "Open this person's Duly member page: open work, this period's duties, standing duties and recent movement.",
icon: 'user-round',
type: 'url',
target: '/apps/ai.objectstack.duly/sys_user/record/${ctx.recordId}/page/duly_member',
locations: ['record_header'],
});
43 changes: 28 additions & 15 deletions src/pages/member.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,9 @@ import { definePage } from '@objectstack/spec/ui';
* ═══════════════════════════════════════════════════════════════════════════
*
* Each of these was measured against the installed 17.2.0 packages before it
* was written down, and each is FILED at `objectstack-ai/objectui` rather than
* faked here. What the page does INSTEAD is named in each entry, and every
* was written down, and each is FILED upstream rather than faked here: at
* `objectstack-ai/objectui`, except §5, which was re-measured on 18.2.0 and is
* filed at `objectstack-ai/objectstack`. What the page does INSTEAD is named in each entry, and every
* entry carries its issue number — a gap with no number is a workaround
* wearing a comment (AGENTS.md rule 9).
*
Expand Down Expand Up @@ -146,18 +147,30 @@ import { definePage } from '@objectstack/spec/ui';
* error, and "showing who assigned each" is the one line of this card that
* is not fully delivered. Recorded on the issue and filed upstream.
*
* **§5 — Two record pages for one object resolve by declaration order**
* (objectui#7298).
* **§5 — Opening a person does NOT land here; plugin-auth's default wins**
* (objectstack#28979; on 17.2 it was objectui#7298).
* `@objectstack/platform-objects` ships `sys_user_detail`
* (`type: 'record'`, `object: 'sys_user'`, `isDefault: true`), and
* `usePageAssignment` picks among candidates by `(b.priority ?? 0) -
* (a.priority ?? 0)` — but `priority` is not a key `PageSchema` declares, so an
* author cannot write the tiebreaker the renderer reads. With both pages at 0
* the winner is metadata load order.
* → INSTEAD: nothing, because there is nothing authorable. `isDefault` is
* left `false` here so this page never claims to be the default while the
* platform's page claims the same thing, and the observed resolution on
* this checkout is recorded in the PR.
* (`type: 'record'`, `object: 'sys_user'`, `isDefault: true`). On 17.2 the
* console chose between the two pages by list order, and this page happened
* to come first. From 18 on, the console picks the `isDefault` page first
* (`candidates.find(p => defaultRecordPageObject(p) === object) ??
* candidates[0]`), so, measured on 18.2.0, Duly > People > a person opens
* plugin-auth's admin page and this page is unreachable by the plain record
* route. Nothing authorable outranks it. `PageSchema` has no priority key.
* `isDefault: true` here would be a second default for `sys_user`, which
* refuses the boot (`DEFAULT_RECORD_PAGE_CONFLICT`, ruling A on
* objectstack#23015). plugin-auth's opt-out exists only for its organization
* page. And the console's choice ignores the app, so a deployment-wide
* `sys_user` default would also replace Setup > Users.
* → INSTEAD: `duly_member_open` (`src/actions/member.actions.ts`), a
* `type: 'url'` action on `sys_user` that opens this page's explicit route
* `/apps/<app>/sys_user/record/<id>/page/duly_member`, from the record's
* "⋯" menu. It is not on the People row menu, because 18.2 runs a row
* url action with no record in context. That is one click more than it
* should be, labelled a stopgap, and deleted when #28979 lands.
* `isDefault` stays `false`. `test/member-page.test.ts` mirrors the
* console's rule and pins that a person resolves to `sys_user_detail`
* today, so the day that changes is a red test, not a silent one.
*
* **§7 — The auto-appended discussion panel cannot be declined by a page, and
* it carries a comment box** (objectui#7298, the second instance there). Found in the browser, not in the schema.
Expand Down Expand Up @@ -207,8 +220,8 @@ export const MemberPage = definePage({
kind: 'full',

// Left FALSE deliberately — see gap §5. The platform's own `sys_user_detail`
// already declares `isDefault: true` for this object, and two pages both
// claiming it would make the flag a lie on whichever one loses.
// already declares `isDefault: true` for this object, and from 18 on a
// second default refuses the boot (DEFAULT_RECORD_PAGE_CONFLICT).
isDefault: false,

regions: [
Expand Down
9 changes: 9 additions & 0 deletions src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,15 @@ const TASK_BULK_ACTIONS = {
export const dulyChinese = defineTranslationBundle({
'zh-CN': {
objects: {
// ── sys_user — 平台对象,这里只翻译 Duly 挂上去的动作 ─────────────
sys_user: {
_actions: {
duly_member_open: {
label: '打开成员页',
description: '打开此人的 Duly 成员页:待办工作、本周期职责、常设职责与近期动态。',
},
},
},
// ── duly_duty — 职责:产生任务的那条规则 ─────────────────────────
duly_duty: {
label: '职责',
Expand Down
98 changes: 93 additions & 5 deletions test/member-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { readFileSync } from 'node:fs';

import { describe, expect, it } from 'vitest';

import { SysUserDetailPage } from '@objectstack/platform-objects';
import { defaultRecordPageObject } from '@objectstack/spec/ui';

import stack from '../objectstack.config.js';
import { MemberOpenAction, dulyActions } from '../src/actions/index.js';
import { MemberPage } from '../src/pages/member.page.js';
import { dulyPages } from '../src/pages/index.js';
import { dulyApps } from '../src/apps/index.js';
Expand Down Expand Up @@ -88,11 +93,11 @@ describe('duly_member — wiring', () => {

it('does not claim to be the default page for sys_user', () => {
// `@objectstack/platform-objects` ships `sys_user_detail` with
// `isDefault: true` for this same object, and `usePageAssignment` breaks
// the tie on a `priority` key `PageSchema` does not declare — so an author
// cannot write the tiebreaker the renderer reads. Two pages both claiming
// the default would make the flag a lie on whichever one loses; this one
// does not claim it. See the page's gap §5.
// `isDefault: true` for this same object. From 18 on, a second default
// record page for one object is REFUSED at registration
// (`DEFAULT_RECORD_PAGE_CONFLICT`, ruling A on objectstack#23015), so
// `true` here would not win the tie — it would stop the boot. See the
// page's gap §5 and the resolution pins below.
expect(page.isDefault).toBe(false);
});

Expand Down Expand Up @@ -131,6 +136,89 @@ describe('duly_member — wiring', () => {
});
});

// ─── Which page opening a person lands on (#113) ─────────────────────────

/**
* The console's record-page choice, copied from `@objectstack/console` 18.2.0
* (`usePageAssignment`, framework chunk) and measured live on `pnpm demo`:
*
* candidates.find((p) => defaultRecordPageObject(p) === object) ?? candidates[0]
*
* where `candidates` are the served record pages bound to `object`, in
* `/api/v1/meta/page` order. The `isDefault` page wins; list order only breaks
* the case where no page claims the default.
*
* This is a COPY of a rule that lives in the console bundle, and a copy cannot
* see the console change. What it can see is the two inputs that are on disk:
* our page's `isDefault` and plugin-auth's. Re-measure the rule in the browser
* after every platform upgrade (open one person from People) and update this
* copy with what you find. 17.2 resolved by list order alone, which is why the
* original card asked for a "first in the list" pin; on 18.2 that pin would
* pass while the member page was unreachable.
*/
const resolveRecordPage = (pages: readonly unknown[], object: string): string | undefined => {
const candidates = (pages as Rec[]).filter(
(candidate) => (candidate.type ?? 'record') === 'record' && candidate.object === object,
);
const chosen = candidates.find((candidate) => defaultRecordPageObject(candidate) === object) ?? candidates[0];
return chosen?.name as string | undefined;
};

describe('duly_member — what opening a person resolves to (#113)', () => {
// Served order, as measured: the app's pages ahead of plugin-auth's.
const served = [...(dulyPages as unknown[]), SysUserDetailPage];

it('plugin-auth still ships its own default record page for sys_user', () => {
// The input that decides the outcome. If this flips, re-measure: the member
// page may be reachable by the plain record route again.
expect(defaultRecordPageObject(SysUserDetailPage)).toBe('sys_user');
});

it('on 18.2 a person opens sys_user_detail, NOT duly_member', () => {
// The regression this card found, stated rather than hidden. duly_member is
// first in the served list and still loses: no key on PageSchema lets an
// app outrank another package's default (gap §5).
expect(resolveRecordPage(served, 'sys_user')).toBe('sys_user_detail');
});

it('control: without the platform default, the same rule picks duly_member', () => {
// So the pin above is measuring plugin-auth's default, not a rule that
// could never pick our page.
expect(resolveRecordPage(dulyPages as unknown[], 'sys_user')).toBe('duly_member');
});
});

describe('duly_member — the stopgap that reaches it (objectstack#28979)', () => {
const action = MemberOpenAction as unknown as Rec;

it('is in the actions barrel, bound to sys_user', () => {
// Missing from the barrel it type-checks and never renders: the member
// page would be unreachable again with nothing red.
expect(dulyActions as unknown[]).toContain(MemberOpenAction);
expect(action.objectName).toBe('sys_user');
});

it('navigates to the explicit page route for the person in context', () => {
// The route the console serves a named record page on, measured on 18.2:
// /apps/<app route segment>/<object>/record/<id>/page/<page>. The segment
// is the package id; a rename of the page or the package must move this.
const appSegment = (stack as unknown as { manifest: { id: string } }).manifest.id;
expect(action.type).toBe('url');
expect(action.target).toBe(
`/apps/${appSegment}/sys_user/record/\${ctx.recordId}/page/${MemberPage.name}`,
);
// In place, not a new tab: an in-app path's default when `openIn` is unset.
expect(action.openIn).toBeUndefined();
});

it('is offered on the person record, and not on the People row', () => {
// 18.2 runs a row `url` action with no record in context, so the row
// version opens a broken route (see member.actions.ts). Adding
// `list_item` back needs a browser re-measure first, not just this line.
expect(action.locations).toEqual(['record_header']);
});
});

// ─── ⛔ The work log is not on this page, in any form ─────────────────────

describe('duly_member — `duly_log_entry` is absent', () => {
Expand Down
61 changes: 61 additions & 0 deletions test/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
dulySharingRules,
} from '../src/security/index.js';
import { dulyActions } from '../src/actions/index.js';
import { registerDulyActionHandlers } from '../src/actions/register-handlers.js';
import type { HandlerRegistrationContext } from '../src/actions/register-handlers.js';
import stack from '../objectstack.config.js';
import { CatalogItem, Duty, LogEntry, Task, Assignment } from '../src/objects/index.js';

Expand Down Expand Up @@ -333,6 +335,33 @@ describe('manager and admin inherit rather than restate', () => {
* writes. `requiredPermissions` is the only boundary there is, and these
* assertions are what keep it from becoming a string that nothing grants.
*/
/** Action names that have a handler registered through the REAL registration function. */
const HANDLED_ACTIONS = (() => {
const names = new Set<string>();
registerDulyActionHandlers({
registerAction: (...args: unknown[]) => names.add(String(args[1])),
} as unknown as HandlerRegistrationContext);
return names;
})();

/**
* The one action shape exempt from the capability gate: it navigates inside
* Duly and runs nothing. `type: 'url'`, no registered handler, no `operation`,
* no `patch`, no `body`, and a target that is an in-app path into this app.
*/
const isInAppNavigation = (action: Record<string, unknown> | { name: string }): boolean => {
const a = action as Record<string, unknown>;
return (
a.type === 'url' &&
!HANDLED_ACTIONS.has(String(a.name)) &&
a.operation === undefined &&
a.patch === undefined &&
a.body === undefined &&
typeof a.target === 'string' &&
a.target.startsWith('/apps/ai.objectstack.duly/')
);
};

describe('action capability gates', () => {
const byName = new Map(dulyActions.map((a) => [a.name, a]));
const GATES: Record<string, string> = {
Expand All @@ -347,13 +376,45 @@ describe('action capability gates', () => {
// Written over `dulyActions` rather than over the five known names, so a
// SIXTH action added later fails here instead of shipping ungated.
for (const action of dulyActions) {
if (isInAppNavigation(action)) continue;
expect(
action.requiredPermissions?.length,
`${action.name} declares no requiredPermissions — its handler runs on the RLS-bypassing facade`,
).toBeGreaterThan(0);
}
});

it('exempts exactly one shape: handler-less in-app navigation into Duly (#113)', () => {
// The gate exists because a handler runs on the RLS-bypassing facade. A
// `type: 'url'` action with no handler, no operation and no patch runs
// nothing: the console navigates, and the page it lands on reads under
// the viewer's own grants. `duly_member_open` is that shape (seat ruling
// on duly#113: ungated). Each control below is one step off it and must
// still be held to the gate.
const hop = {
name: 'duly_member_open',
type: 'url',
target: '/apps/ai.objectstack.duly/sys_user/record/${ctx.recordId}/page/duly_member',
};
expect(isInAppNavigation(hop)).toBe(true);
expect(dulyActions.filter(isInAppNavigation).map((a) => a.name)).toEqual(['duly_member_open']);

const controls: Array<[string, Record<string, unknown>]> = [
['an external target', { ...hop, target: 'https://example.com/x' }],
['a protocol-relative target', { ...hop, target: '//evil.example/apps/ai.objectstack.duly/' }],
['another app\'s route', { ...hop, target: '/apps/com.objectstack.setup/sys_user' }],
['a template-led target', { ...hop, target: '${ctx.origin}/apps/ai.objectstack.duly/x' }],
['an operation', { ...hop, operation: 'update' }],
['a patch', { ...hop, patch: { status: 'done' } }],
['a script body', { ...hop, body: 'return 1' }],
['a registered handler', { ...hop, name: 'duly_task_undo' }],
['a non-url type', { ...hop, type: 'script' }],
];
for (const [label, action] of controls) {
expect(isInAppNavigation(action), `${label} must not be exempt`).toBe(false);
}
});

for (const [actionName, capability] of Object.entries(GATES)) {
it(`${actionName} requires ${capability}`, () => {
expect(byName.get(actionName)?.requiredPermissions).toEqual([capability]);
Expand Down
Loading