Skip to content

Commit e6f3eb1

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-22605-skill-list-views
2 parents aa50b8f + 83b8b80 commit e6f3eb1

36 files changed

Lines changed: 2450 additions & 139 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-audit': minor
3+
'@objectstack/spec': minor
4+
---
5+
6+
`sys_comment_reaction`: an emoji reaction to a comment is now the reacting user's own record, one row per comment, emoji and user
7+
8+
Clause-②: yes (widening)
9+
10+
- **The object.** `@objectstack/plugin-audit` registers `sys_comment_reaction` beside `sys_comment`. A row holds `comment_id` (the reacted comment's id), `emoji` (the character sequence, at most 64 characters) and `user_id` (the user who reacted). The data API offers `get`, `list`, `create`, `delete` and `bulk` on it, so `createMany`, `deleteMany` and `/batch` creates and deletes work too. It offers no `update`, so `updateMany` stays refused: a reaction is removed and made again, not edited. Every row of a batch meets the same rules as a single write.
11+
- **Who may read it.** A reaction is readable exactly when its comment is. Every read of the object (`find`, `findOne`, `count`, `aggregate`, and so the list total) is narrowed to the rows whose comment the caller can read, decided by a read of those comments under the caller's own context. A reaction whose comment was deleted is read by nobody.
12+
- **Who may write it.** A caller reacts only to a comment it can read; on any other comment, including one that does not exist, the create is refused with `403 RECORD_NOT_ACCESSIBLE`. `user_id` is set from the signed-in user, and a value the client sends is replaced; a session with no user is refused. A member deletes their own reaction, and not another member's, by the platform's own-record rule (`owner_only_deletes`). The author-only edit rule on `sys_comment` is unchanged: reacting no longer writes the comment's row.
13+
- **One row per reaction.** `(comment_id, emoji, user_id)` is unique within an organization. Two members reacting at once write two rows. The same reaction a second time is refused, and the data API answers `409 UNIQUE_VIOLATION`.
14+
- **Reading reactions for a feed.** Read the reactions of the comments on screen in one request, `comment_id` `$in` those ids, and group the rows by comment and emoji. Nothing is stored or computed on the comment row.
15+
- **Granting it.** The object's create, read and delete bits come from a permission set, as `sys_comment`'s do. The `everyone`-bound baseline (`member_default`) grants nothing on it.
16+
- **`@objectstack/spec`: the platform object registry lists it.** `PLATFORM_OBJECTS_BY_PACKAGE['plugin-audit']` now includes `sys_comment_reaction`, and so do `PLATFORM_PROVIDED_OBJECT_NAMES` and `isPlatformProvidedObjectName`. A stack that references the object is no longer reported as naming an unknown platform object. This is the only change in the package.
17+
- **`sys_comment.reactions` is unchanged in this release.** The column is still declared and still written by clients that write it. It is retired separately, and reactions stored in it are not converted into records.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
feat(spec)!: `PageSchema` refuses a page that authors both `slots.details` and `slots.tabs`; the `sys_user` record page carries its details grid as its first tab
7+
8+
Clause-②: yes (narrowing)
9+
10+
<!-- adr-0087: registered page-slots-details-beside-tabs-refused -->
11+
12+
**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.
13+
14+
**Why.** The slot map read as seven independent slots, and two of them are not. On a slotted record page `details` replaces the body of the Details tab, that tab lives inside the synthesized `page:tabs` strip, and `tabs` replaces the whole strip. The console's default-page synthesizer therefore reads `tabs` and never reads `details` when both are authored. A page authoring both passed `PageSchema.parse`, `objectstack validate` and the metadata save door, and its details body (its `sections`, its `hideFields`) silently never applied. The platform's own `sys_user` record page was one: its Identity and Audit sections never showed, and the ban columns it hides were never hidden by it.
15+
16+
**What is refused.** A page whose `slots` map carries both a `details` and a `tabs` key, at `slots.details`, with a `custom` issue that names both slots and the fix. Either slot may be one component or an array; an empty `details: []` is refused like a full one, and the page's `kind` does not matter. That covers `PageSchema`, `definePage`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), `os validate` and the metadata save door (`422 INVALID_METADATA`). The check is exported as `checkPageSlotPair`, for a mirror built from `PageSchema.shape` to re-attach.
17+
18+
**What is still accepted, byte for byte.** A page authoring only `details`, only `tabs`, or neither.
19+
20+
## FROM → TO
21+
22+
| you wrote | write instead |
23+
|:--|:--|
24+
| `slots: { details: { type: 'record:details', … }, tabs: { type: 'page:tabs', properties: { items: [ … ] } } }` | `slots: { tabs: { type: 'page:tabs', properties: { items: [ { label: 'Details', children: [ { type: 'record:details', … } ] }, … ] } } }` |
25+
| both slots, wanting the synthesized tabs with your details body | `slots: { details: { type: 'record:details', … } }`, with no `tabs` slot |
26+
27+
**The one-line fix: move the `record:details` component into the `tabs` items (the first item's `children`, by convention) and delete `slots.details`.** Its `sections` and `hideFields` move unchanged. The page then shows the details body it always declared, which is a visible change on every page that authored the pair.
28+
29+
**Who is affected, measured.** In this repository, only the `sys_user` record page (`sys_user_detail` in `@objectstack/platform-objects`) authored both slots; no example app page does. hotcrm's one slotted page authors `header` and `discussion` only. Deployed metadata was not measured. A page row already stored with the pair is replayed unchanged at load, so it renders as before (the authored tabs, without the details body); its read diagnostics name the pair, and saving it again is refused until the details body moves.
30+
31+
### The kit
32+
33+
- **The refusal.** `checkPageSlotPair`, attached to `PageSchema` by identifier beside its three other object-level checks. No new error code.
34+
- **The producer.** `sys_user_detail` moves its `record:details`, with its `sections` and `hideFields` unchanged, in as the first `tabs` item.
35+
- **The ledger.** The D3 semantic entry `page-slots-details-beside-tabs-refused` (protocol 18) and its step-18 rationale fragment. No key is removed, so there is no tombstone, and there is no D2 conversion: which tab item carries the details body, and under which label, is the author's decision.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
fix(plugin-approvals): the generic data door serves `sys_approval_action` and `sys_approval_approver` rows by their request's visibility
6+
7+
Clause-②: no
8+
9+
A read of a request's two child tables through the generic data door (`find`, `findOne`, `count` and `aggregate`, so the REST data routes, the export route, the MCP data tool and every other engine read) now returns only the rows whose request the approvals door (`/api/v1/approvals/*`) serves the same caller:
10+
11+
- `sys_approval_action`, the decision log (actor, decision, comment text);
12+
- `sys_approval_approver`, the pending-approver index.
13+
14+
Before this, a deployment that granted read on either object served every row of the organization on that door, including the decision log and comment text of requests the caller does not take part in.
15+
16+
- **One rule for both doors, and for all three tables.** The gates ask the approvals service the same question the `sys_approval_request` gate asks, keyed by the row's `request_id`: the request's submitter, its current approvers and those who acted on it see its rows; an administrator holding the approval override sees every row; and, read-only, on an object named in `recordReaderVisibleObjects`, a reader of the record sees its request's rows on a read that names the request (`request_id`) or the row (its id). The published `ApprovalService` type is unchanged.
17+
- **The record page's Timeline keeps its readers.** A request's Timeline (a list of `sys_approval_action` keyed on `request_id`) serves the rows `listActions` serves the same caller; a row read by id is served exactly when a decision attachment on it would be (`authorizeFileRead`, which now shares the gate's read of the row's request).
18+
- **Absence, not refusal.** A row the caller may not see answers a by-id read exactly as a missing one does, `404 RECORD_NOT_FOUND`. A list's `total` and a grouped count are narrowed like its rows. A failed visibility answer denies the read and logs a warning.
19+
- **Unchanged.** The approvals door, an administrator's view, and the approval engine's own reads. A deployment that grants no read on either object still answers `403`.
20+
- The record-reader tier no longer anchors on a row of either table, even when `recordReaderVisibleObjects` names it, as it already never anchored on `sys_approval_request`.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #22568 — the user page's details body is the FIRST `tabs` item, not a
5+
* `details` slot.
6+
*
7+
* The `tabs` slot replaces the whole tab strip the synthesized Details tab
8+
* lives in, so a `details` slot authored beside it never rendered: the page's
9+
* Identity and Audit sections never showed, and the admin-internal columns it
10+
* hides (`ban_reason`, `ban_expires`, …) were never hidden by it.
11+
* `PageSchema` now refuses the pair, and the page carries its `record:details`
12+
* inside the strip it authors.
13+
*
14+
* Read from the shipped page metadata, through the spec's own page contract,
15+
* so the pin fails if the page stops parsing and if the details body moves
16+
* out of the first tab or loses a section or a hidden field.
17+
*/
18+
19+
import { describe, expect, it } from 'vitest';
20+
import { PageSchema } from '@objectstack/spec/ui';
21+
import { SysUserDetailPage } from './sys-user.page.js';
22+
23+
type Rec = Record<string, unknown>;
24+
25+
/** The details body as the page authored it before the move — sections and hidden fields unchanged. */
26+
const HIDE_FIELDS = [
27+
'id',
28+
'banned',
29+
'ban_reason',
30+
'ban_expires',
31+
'email',
32+
'phone_number',
33+
'email_verified',
34+
'two_factor_enabled',
35+
'role',
36+
];
37+
const SECTIONS = [
38+
{
39+
label: { en: 'Identity', 'zh-CN': '身份', 'ja-JP': 'アイデンティティ', 'es-ES': 'Identidad' },
40+
fields: ['name', 'image'],
41+
},
42+
{
43+
label: { en: 'Audit', 'zh-CN': '审计', 'ja-JP': '監査', 'es-ES': 'Auditoría' },
44+
fields: ['created_at', 'updated_at'],
45+
},
46+
];
47+
48+
describe('sys_user_detail — the details body lives in the first tab', () => {
49+
const slots = SysUserDetailPage.slots as Rec;
50+
const tabs = slots.tabs as Rec;
51+
const items = (tabs.properties as Rec).items as Rec[];
52+
53+
it('parses through PageSchema — the slot-pair refusal does not fire on the shipped page', () => {
54+
const parsed = PageSchema.safeParse(SysUserDetailPage);
55+
expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true);
56+
});
57+
58+
it('authors no `details` slot beside its `tabs` slot', () => {
59+
expect(slots).not.toHaveProperty('details');
60+
expect(tabs.type).toBe('page:tabs');
61+
});
62+
63+
it('carries the `record:details` as the only child of the FIRST tab item, sections and hidden fields intact', () => {
64+
const first = items[0]!;
65+
expect((first.label as Rec).en).toBe('Details');
66+
const children = first.children as Rec[];
67+
expect(children.map((c) => c.type)).toEqual(['record:details']);
68+
const props = children[0]!.properties as Rec;
69+
expect(props.hideFields).toEqual(HIDE_FIELDS);
70+
expect(props.sections).toEqual(SECTIONS);
71+
});
72+
73+
it('is the one `record:details` on the page — no second copy anywhere in the slot map', () => {
74+
const found: unknown[] = [];
75+
const walk = (n: unknown): void => {
76+
if (Array.isArray(n)) { n.forEach(walk); return; }
77+
if (!n || typeof n !== 'object') return;
78+
if ((n as Rec).type === 'record:details') found.push(n);
79+
Object.values(n as Rec).forEach(walk);
80+
};
81+
walk(slots);
82+
expect(found).toHaveLength(1);
83+
});
84+
});

‎packages/platform-objects/src/pages/sys-user.page.ts‎

Lines changed: 58 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,26 @@ import type { Page } from '@objectstack/spec/ui';
1717
*
1818
* Strategy
1919
* --------
20-
* - `kind: 'slotted'` + `isDefault: true`: overrides `highlights`,
21-
* `details`, `tabs` and `discussion`. Header / actions fall through
20+
* - `kind: 'slotted'` + `isDefault: true`: overrides `alerts`,
21+
* `highlights`, `tabs` and `discussion`. Header / actions fall through
2222
* to the synthesizer so the object's declared actions
2323
* (`update_my_profile / change_my_password / resend_verification_email
2424
* / ban_user / impersonate_user / …`) still appear
2525
* in the header overflow menu automatically.
2626
* - `highlights` promotes the four signals worth scanning at the top:
2727
* email, verification state, 2FA, platform role. Highlight fields
2828
* are auto-dropped from the details grid below.
29-
* - `details` re-groups remaining fields into sections and hides
30-
* admin-internal audit columns. Banned / ban metadata is still
31-
* editable from the header actions — we just don't show it in
32-
* every user's body.
33-
* - `tabs` is **explicitly curated** to the 5 related lists that matter
34-
* on a user profile (Positions / Sessions / Linked Accounts / Organizations /
35-
* Personal OAuth Apps). Without this override, the synthesizer
29+
* - The FIRST `tabs` item is the details grid: a `record:details` that
30+
* re-groups remaining fields into sections and hides admin-internal
31+
* audit columns. Banned / ban metadata is still editable from the
32+
* header actions — we just don't show it in every user's body. It is
33+
* a tab item and not a `details` slot because the `tabs` slot replaces
34+
* the whole tab strip the Details tab lives in: a `details` slot beside
35+
* `tabs` never rendered, and `PageSchema` refuses the pair.
36+
* - The other `tabs` items are **explicitly curated** to the related lists
37+
* that matter on a user profile (Positions / Permission Sets / Business
38+
* Units / Sessions / Linked Accounts / Organizations / OAuth Apps / API
39+
* Keys) plus a Security tab. Without this override, the synthesizer
3640
* auto-generates a tab per object that has a FK to sys_user
3741
* (sys_position.created_by, sys_email.updated_by, sys_user_preference,
3842
* sys_email_template.created_by, …) producing dozens of noisy
@@ -125,47 +129,61 @@ export const SysUserDetailPage: Page = {
125129
},
126130
},
127131

128-
// ── Body / details grid ───────────────────────────────────────
129-
details: {
130-
type: 'record:details',
131-
properties: {
132-
hideFields: [
133-
'id',
134-
'banned',
135-
'ban_reason',
136-
'ban_expires',
137-
// already promoted to highlights:
138-
'email',
139-
'phone_number',
140-
'email_verified',
141-
'two_factor_enabled',
142-
'role',
143-
],
144-
sections: [
145-
{
146-
label: { en: 'Identity', 'zh-CN': '身份', 'ja-JP': 'アイデンティティ', 'es-ES': 'Identidad' },
147-
fields: ['name', 'image'],
148-
},
149-
{
150-
label: { en: 'Audit', 'zh-CN': '审计', 'ja-JP': '監査', 'es-ES': 'Auditoría' },
151-
fields: ['created_at', 'updated_at'],
152-
},
153-
],
154-
},
155-
},
156-
157-
// ── Tabs: curated related lists ───────────────────────────────
158-
// Only the 4 lists that are semantically about THIS user account.
132+
// ── Tabs: the details grid, then curated related lists ────────
133+
// Only the lists that are semantically about THIS user account.
159134
// Everything else (sys_position created_by, sys_email_template
160135
// updated_by, …) is incidental authorship metadata and would only
161136
// create noise.
137+
//
138+
// There is no `details` slot: the `tabs` slot replaces the whole tab
139+
// strip the synthesized Details tab lives in, so a `details` slot beside
140+
// it never rendered and `PageSchema` refuses the pair (#22568). The
141+
// details grid is therefore the FIRST tab's body here.
162142
tabs: {
163143
type: 'page:tabs',
164144
properties: {
165145
// `tabStyle`, not `type` (#6776) — see sys-organization.page.ts.
166146
tabStyle: 'line',
167147
position: 'top',
168148
items: [
149+
{
150+
// ── Body / details grid ───────────────────────────────
151+
// Re-groups the remaining fields into sections and hides
152+
// admin-internal columns. Banned / ban metadata is still
153+
// editable from the header actions — it is just not shown in
154+
// every user's body.
155+
label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' },
156+
icon: 'file-text',
157+
children: [
158+
{
159+
type: 'record:details',
160+
properties: {
161+
hideFields: [
162+
'id',
163+
'banned',
164+
'ban_reason',
165+
'ban_expires',
166+
// already promoted to highlights:
167+
'email',
168+
'phone_number',
169+
'email_verified',
170+
'two_factor_enabled',
171+
'role',
172+
],
173+
sections: [
174+
{
175+
label: { en: 'Identity', 'zh-CN': '身份', 'ja-JP': 'アイデンティティ', 'es-ES': 'Identidad' },
176+
fields: ['name', 'image'],
177+
},
178+
{
179+
label: { en: 'Audit', 'zh-CN': '审计', 'ja-JP': '監査', 'es-ES': 'Auditoría' },
180+
fields: ['created_at', 'updated_at'],
181+
},
182+
],
183+
},
184+
},
185+
],
186+
},
169187
{
170188
label: { en: 'Positions', 'zh-CN': '岗位', 'ja-JP': 'ポジション', 'es-ES': 'Puestos' },
171189
icon: 'shield-check',

0 commit comments

Comments
 (0)