From efb3ee39f962843c16d2a1dc7434a81a8ac69374 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 05:51:04 +0000 Subject: [PATCH] Reach the member page again on 18.2 with a record-header action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On @objectstack 18.2 the console opens the isDefault record page first, so Duly > People > a person opens plugin-auth's sys_user_detail and duly_member is unreachable by the plain record route. PageSchema has no priority key and a second default refuses the boot, so nothing authorable outranks it (filed as objectstack-ai/objectstack#28979). - duly_member_open: a type 'url' action on sys_user that opens the explicit /page/duly_member route from the record's menu. A labelled stopgap. record_header only: 18.2 runs a row url action with no record in context. - test/member-page.test.ts mirrors the console's resolution rule and pins that a person resolves to sys_user_detail today, with a control, plus the route the action builds. - test/security.test.ts exempts exactly handler-less in-app navigation into Duly from the capability gate, with controls one step off that shape. - member.page.ts gap §5 rewritten with the 18.2 measurement. Fixes #113 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JQwxbY2bWFwe8GRYRBqzbg --- src/actions/index.ts | 6 +++ src/actions/member.actions.ts | 69 ++++++++++++++++++++++++ src/pages/member.page.ts | 43 +++++++++------ src/translations/zh-CN.ts | 9 ++++ test/member-page.test.ts | 98 +++++++++++++++++++++++++++++++++-- test/security.test.ts | 61 ++++++++++++++++++++++ 6 files changed, 266 insertions(+), 20 deletions(-) create mode 100644 src/actions/member.actions.ts diff --git a/src/actions/index.ts b/src/actions/index.ts index 693cf97..be5fd90 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -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, @@ -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, ]; diff --git a/src/actions/member.actions.ts b/src/actions/member.actions.ts new file mode 100644 index 0000000..ee2f895 --- /dev/null +++ b/src/actions/member.actions.ts @@ -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//sys_user/record//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'], +}); diff --git a/src/pages/member.page.ts b/src/pages/member.page.ts index 531a1aa..1139629 100644 --- a/src/pages/member.page.ts +++ b/src/pages/member.page.ts @@ -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). * @@ -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//sys_user/record//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. @@ -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: [ diff --git a/src/translations/zh-CN.ts b/src/translations/zh-CN.ts index b2354cb..47099ac 100644 --- a/src/translations/zh-CN.ts +++ b/src/translations/zh-CN.ts @@ -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: '职责', diff --git a/test/member-page.test.ts b/test/member-page.test.ts index 29dabaa..fbeeda5 100644 --- a/test/member-page.test.ts +++ b/test/member-page.test.ts @@ -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'; @@ -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); }); @@ -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///record//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', () => { diff --git a/test/security.test.ts b/test/security.test.ts index 8fec595..7d0c9f1 100644 --- a/test/security.test.ts +++ b/test/security.test.ts @@ -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'; @@ -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(); + 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 | { name: string }): boolean => { + const a = action as Record; + 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 = { @@ -347,6 +376,7 @@ 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`, @@ -354,6 +384,37 @@ describe('action capability gates', () => { } }); + 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]> = [ + ['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]);