Skip to content

Commit 06816ae

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-xn40qf
2 parents 9519b58 + b50f5cf commit 06816ae

39 files changed

Lines changed: 964 additions & 69 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
fix(platform-objects): the zh-CN label for an organization's logo reads 「徽标」, no longer 「标识」, which read as the slug (「标识符」) on the same record page and in the Rename Organization dialog
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`sys_organization.fields.logo.label` in the zh-CN bundle was 「标识」 ("identifier"), directly beside the slug's 「标识符」. The Rename Organization and Edit Organization dialogs take their `logo` param label from this field label, so a zh-CN admin read the logo URL input as the organization's identifier. ja-JP (「ロゴ」 / 「スラッグ」) and es-ES (「Logotipo」 / 「Slug」) had no such collision and are unchanged.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@object-ui/plugin-list': patch
3+
'@object-ui/plugin-grid': patch
4+
'@object-ui/i18n': patch
5+
---
6+
7+
fix(plugin-list,plugin-grid): an empty list tells "no records you can see" and "nothing of yours yet" apart from "no records"
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
An empty list chose its copy from the user's query and the view's filter alone. A member who may read none of an object's records and may not create one was told "Nothing here yet / Create your first record to get started." beside no Create button, and every "my …" view (`owner` equals the current user) told someone who owns nothing "No records match this view's filter."
13+
14+
- **No filter, no create grant.** When the permission answer has loaded and the caller does not hold the create grant (the `listNew` row of the affordance-to-grant map, the verdict the page's own New reads), the empty state reads "No records you can see" and asks the reader to contact an administrator. The add-record action is not drawn for that caller, even on a view that enables `addRecord`.
15+
- **A filter naming the signed-in user.** When the view's declared filter compares a field to the caller's own id (or still carries the `{current_user_id}` placeholder), the empty state reads "Nothing of yours yet", with the add-record action only under the create grant.
16+
- **Unchanged.** The first-run copy and action for a caller who may create, the user-query and view-filter copy, an authored `emptyState`, the 403 error panel, and every surface with no permission provider mounted.
17+
18+
`ListView` (grid and gallery), the standalone `ObjectGallery` and `ObjectGrid`'s default empty state decide the same way. `@object-ui/i18n` gains `list.ownedNoneTitle`, `list.ownedNoneMessage`, `list.noneVisibleTitle` and `list.noneVisibleMessage` in all ten locale packs.
19+
20+
What to do: nothing. A view that authors its own `emptyState` keeps it.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
Every built-in permission set says who holds it, in English, Chinese, Japanese and Spanish
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Setup → Permission Sets lists the platform's own permission sets beside every app's. Five of them (*Administrator — Full Access*, *Organization Administrator*, *Member — Standard Access*, *Viewer — Read-Only* and *External Member — Own Records*) had no description, and the no-bypass organization administrator set and the three MCP agent sets had descriptions written for platform engineers. An administrator picking a set for a new user could not tell an administrator set from a member set.
11+
12+
Each built-in set now has one plain sentence saying who holds it and what it opens or withholds. For example, *Organization Administrator* reads "Owners and admins of an organization: full control of its records and members in Setup, but no app authoring or platform-wide settings." The sentences are translated into zh-CN, ja-JP and es-ES through the permission-set group of the translation bundle (`permissions.<name>.description`), so the page shows them in the user's language. The engineering detail that used to be in the no-bypass and MCP descriptions is now in code comments.
13+
14+
No set's grants, name or label changed. This changes only the text shown about each set, not its permissions.

‎packages/lint/src/lazy-deps.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
// test-budget: 20 — whole-package import: the in-process case imports the whole @objectstack/lint barrel cold and loads each lazy dep on its trigger, and the two built-dist probes each spawn node once to cold-load the dist and every lazy dep (typescript among them); slowest CI reading 13.91 s (run 40200279306, refresh PR #25960's window (hourly runs 40200279306 to 40272142430)) x 1.25, rounded up
23
//
34
// Boot-path contract: importing @objectstack/lint must NOT load its heavy,
45
// gate-only dependencies. The package sits on the kernel boot path, while each

‎packages/lint/src/non-record-object-entry.test.ts‎

Lines changed: 55 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,50 @@ const NON_RECORD_ENTRIES: readonly (readonly [string, unknown])[] = [
8787
];
8888

8989
const stackWith = (junk: unknown): AnyRec => ({ objects: [junk, VALID_OBJECT] });
90-
const validOnly: AnyRec = { objects: [VALID_OBJECT] };
90+
const validOnly = (): AnyRec => ({ objects: [VALID_OBJECT] });
9191

92-
const findingCount = (stack: AnyRec): number =>
93-
AUTHORING_RULES.reduce((n, rule) => n + rule.run(stack, {}).length, 0);
92+
/** What one rule did with one stack: the message it threw, or how many findings it returned. */
93+
type RuleOutcome = { readonly rule: string } & ({ readonly threw: string } | { readonly findings: number });
94+
95+
/**
96+
* One sweep per distinct stack. Every rule is pure, `(stack) => Finding[]`, so
97+
* running the table once over a stack and recording what each rule did answers
98+
* both questions a case asks of that stack — did any rule throw, and how many
99+
* findings did the rest return. The "throws" case and the "count" case of one
100+
* stack read the same sweep, and a control stack shared by every junk shape is
101+
* swept once. Each rule still gets a stack of its own, built fresh.
102+
*/
103+
const sweeps = new Map<string, readonly RuleOutcome[]>();
104+
const sweep = (key: string, build: () => AnyRec): readonly RuleOutcome[] => {
105+
let outcomes = sweeps.get(key);
106+
if (!outcomes) {
107+
outcomes = AUTHORING_RULES.map((rule): RuleOutcome => {
108+
try {
109+
return { rule: rule.name, findings: rule.run(build(), {}).length };
110+
} catch (e) {
111+
return { rule: rule.name, threw: (e as Error).message };
112+
}
113+
});
114+
sweeps.set(key, outcomes);
115+
}
116+
return outcomes;
117+
};
118+
119+
/** `rule: message` for every rule that threw. */
120+
const throwsOf = (outcomes: readonly RuleOutcome[]): string[] =>
121+
outcomes.flatMap((o) => ('threw' in o ? [`${o.rule}: ${o.threw}`] : []));
122+
123+
/**
124+
* The finding count over every rule not in `skip`. A rule outside `skip` that
125+
* threw fails the count with its own message rather than counting as zero, as
126+
* the count did when it ran the rules itself.
127+
*/
128+
const countOf = (outcomes: readonly RuleOutcome[], skip: readonly string[] = []): number =>
129+
outcomes.reduce((n, o) => {
130+
if (skip.includes(o.rule)) return n;
131+
if ('threw' in o) throw new Error(`${o.rule}: ${o.threw}`);
132+
return n + o.findings;
133+
}, 0);
94134

95135
describe('a non-record entry in `stack.objects` (#15552)', () => {
96136
/**
@@ -103,23 +143,17 @@ describe('a non-record entry in `stack.objects` (#15552)', () => {
103143
expect(AUTHORING_RULES.length).toBeGreaterThanOrEqual(42);
104144
});
105145

106-
describe.each(NON_RECORD_ENTRIES)('with %s beside a valid object', (_label, junk) => {
146+
describe.each(NON_RECORD_ENTRIES)('with %s beside a valid object', (label, junk) => {
147+
const withJunk = (): readonly RuleOutcome[] => sweep(`objects · ${label}`, () => stackWith(junk));
148+
107149
it('no authoring rule throws', () => {
108-
const threw: string[] = [];
109-
for (const rule of AUTHORING_RULES) {
110-
try {
111-
rule.run(stackWith(junk), {});
112-
} catch (e) {
113-
threw.push(`${rule.name}: ${(e as Error).message}`);
114-
}
115-
}
116-
expect(threw).toEqual([]);
150+
expect(throwsOf(withJunk())).toEqual([]);
117151
});
118152

119153
it('still judges the valid object standing beside it', () => {
120-
const baseline = findingCount(validOnly);
154+
const baseline = countOf(sweep('objects · valid only', validOnly));
121155
expect(baseline).toBeGreaterThan(0);
122-
expect(findingCount(stackWith(junk))).toBe(baseline);
156+
expect(countOf(withJunk())).toBe(baseline);
123157
});
124158
});
125159

@@ -575,19 +609,12 @@ describe('a non-record entry in any other stack collection (#15636)', () => {
575609

576610
describe.each(NON_RECORD_ENTRIES)('with %s', (shape, junk) => {
577611
const key = `${collection.label} · ${shape}`;
612+
const withJunk = (): readonly RuleOutcome[] => sweep(key, () => collection.stack(members(junk)));
578613

579614
it('throws out of no rule but the ones still filed as broken', () => {
580-
const threw: string[] = [];
581-
const detail: string[] = [];
582-
for (const rule of AUTHORING_RULES) {
583-
try {
584-
rule.run(collection.stack(members(junk)), {});
585-
} catch (e) {
586-
threw.push(rule.name);
587-
detail.push(`${rule.name}: ${(e as Error).message}`);
588-
}
589-
}
590-
expect(threw.sort(), detail.join(' | ')).toEqual([...(RESIDUAL_THROWS[key] ?? [])].sort());
615+
const outcomes = withJunk();
616+
const threw = outcomes.flatMap((o) => ('threw' in o ? [o.rule] : []));
617+
expect(threw.sort(), throwsOf(outcomes).join(' | ')).toEqual([...(RESIDUAL_THROWS[key] ?? [])].sort());
591618
});
592619

593620
it('invents no finding about the entry no author wrote', () => {
@@ -596,9 +623,8 @@ describe('a non-record entry in any other stack collection (#15636)', () => {
596623
// folding that into the population count would let a crash read as
597624
// "reported nothing", which is the confusion this file exists to end.
598625
const skip = RESIDUAL_THROWS[key] ?? [];
599-
const count = (stack: AnyRec): number =>
600-
AUTHORING_RULES.reduce((n, rule) => (skip.includes(rule.name) ? n : n + rule.run(stack, {}).length), 0);
601-
expect(count(collection.stack(members(junk))) - count(control())).toBe(RESIDUAL_INVENTED[key] ?? 0);
626+
const controlOutcomes = sweep(`${collection.label} · control`, control);
627+
expect(countOf(withJunk(), skip) - countOf(controlOutcomes, skip)).toBe(RESIDUAL_INVENTED[key] ?? 0);
602628
});
603629
});
604630
});

‎packages/lint/src/runtime-lazy-deps.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
// test-budget: 20 — whole-package import: the in-process cases import the @objectstack/lint/runtime entry cold, which reaches the whole gate graph, and the built-dist probes each spawn node once to cold-load it; slowest CI reading 13.05 s (run 40200279306, refresh PR #25960's window (hourly runs 40200279306 to 40272142430)) x 1.25, rounded up
23
//
34
// The kernel boot-path contract for `@objectstack/lint/runtime` (#4463).
45
//

‎packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
535535
help: "适合 URL 使用的标识符"
536536
},
537537
logo: {
538-
label: "标识"
538+
label: "徽标"
539539
},
540540
metadata: {
541541
label: "元数据",

‎packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,10 @@ describe('sys_comment_reaction delete is ownership-shaped (#22566)', () => {
394394
* And `manage_mailboxes` (ruling 6116489638, Q4 → A on the inbound-mail record —
395395
* platform and organization administrators hold the mailbox administrator
396396
* capability by default).
397+
*
398+
* The set's `description` is admin-facing copy, not capability, so it is left
399+
* out of the comparison (its shape is pinned by
400+
* `permission-set-descriptions.test.ts`).
397401
*/
398402
describe('admin_full_access imports the kernel capability declaration unchanged (#11965)', () => {
399403
it('parsed declaration deep-equals the pre-#11965 inline literal', () => {
@@ -432,7 +436,8 @@ describe('admin_full_access imports the kernel capability declaration unchanged
432436
.map(([name]) => [`sys_user.${name}`, { readable: true, editable: true }]),
433437
),
434438
});
435-
expect(setByName('admin_full_access')).toEqual(preMove);
439+
const { description: _copy, ...declared } = setByName('admin_full_access') as Record<string, unknown>;
440+
expect(declared).toEqual(preMove);
436441
});
437442

438443
it('[#21260] no other shipped set carries the ledger audit capability: every other position holds it only by explicit grant', () => {

‎packages/plugins/plugin-security/src/objects/default-permission-sets.ts‎

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,11 @@ const baseDefaultPermissionSets: PermissionSet[] = [
324324
PermissionSetSchema.parse({
325325
name: ADMIN_FULL_ACCESS,
326326
label: 'Administrator — Full Access',
327+
// Admin-facing copy on Setup → Permission Sets: one business sentence, no
328+
// engineering terms (the detail lives in the comments beside each set).
329+
// `permission-set-descriptions.test.ts` pins the shape for every set here.
330+
description:
331+
'Platform operators: read and change every record in every organization, manage all users and settings, and author apps in Studio.',
327332
// [#11965 / #11663 Choice 6A] The capability CONTENT (object wildcard +
328333
// `systemPermissions`) is the kernel platform-admin declaration exported
329334
// by `@objectstack/spec` — one list, imported here and read by core's
@@ -378,6 +383,10 @@ const baseDefaultPermissionSets: PermissionSet[] = [
378383
PermissionSetSchema.parse({
379384
name: ORGANIZATION_ADMIN,
380385
label: 'Organization Administrator',
386+
// Inherited by `organization_admin_no_bypass` through the derivation's
387+
// spread, which then writes its own.
388+
description:
389+
'Owners and admins of an organization: full control of its records and members in Setup, but no app authoring or platform-wide settings.',
381390
objects: {
382391
'*': {
383392
allowRead: true,
@@ -590,6 +599,10 @@ const baseDefaultPermissionSets: PermissionSet[] = [
590599
PermissionSetSchema.parse({
591600
name: 'member_default',
592601
label: 'Member — Standard Access',
602+
// No business object is named here (see the NO-WILDCARD note below), so the
603+
// copy says where business access comes from instead.
604+
description:
605+
"Every signed-in member's baseline: their own profile, keys and inbox and the member directory; business records come from their other sets.",
593606
objects: {
594607
// [#5491] NO `'*'` WILDCARD GRANT. This set is the additive `everyone`
595608
// baseline — it resolves for EVERY authenticated member — and object
@@ -1130,6 +1143,10 @@ const baseDefaultPermissionSets: PermissionSet[] = [
11301143
PermissionSetSchema.parse({
11311144
name: 'viewer_readonly',
11321145
label: 'Viewer — Read-Only',
1146+
// A read wildcard without the superuser bits: row visibility is still
1147+
// sharing's, so the copy says "as far as sharing allows", not "every record".
1148+
description:
1149+
'Read-only users: read records across every object, as far as sharing allows, but create, edit or delete nothing.',
11331150
objects: {
11341151
'*': {
11351152
allowRead: true,
@@ -1282,6 +1299,8 @@ const baseDefaultPermissionSets: PermissionSet[] = [
12821299
PermissionSetSchema.parse({
12831300
name: EXTERNAL_DEFAULT_PERMISSION_SET,
12841301
label: 'External Member — Own Records',
1302+
description:
1303+
'People from outside the organization, such as customers or suppliers: their own profile and records, plus only what is shared with them.',
12851304
objects: {
12861305
// Self-service profile edits, bounded exactly as `member_default`'s
12871306
// (`sys_user_self` below + the ADR-0092 D2 column whitelist).
@@ -1475,20 +1494,24 @@ const baseDefaultPermissionSets: PermissionSet[] = [
14751494
PermissionSetSchema.parse({
14761495
name: MCP_AGENT_PERMISSION_SET_READ,
14771496
label: 'MCP Agent — Read Only',
1497+
// The read-only ceiling for an agent acting on behalf of a user, from the
1498+
// OAuth `data:read` scope; bounded by the delegating user's own
1499+
// permissions (the D10 intersection above).
14781500
description:
1479-
'Read-only ceiling for an AI agent acting on behalf of a user (OAuth `data:read`). ' +
1480-
'Bounded by the delegating user\'s own permissions.',
1501+
'AI assistants a user connects with read access: they read what that user can read and change nothing.',
14811502
objects: {
14821503
'*': { allowRead: true },
14831504
},
14841505
}),
14851506
PermissionSetSchema.parse({
14861507
name: MCP_AGENT_PERMISSION_SET_WRITE,
14871508
label: 'MCP Agent — Read & Write',
1509+
// The read+write ceiling for an agent acting on behalf of a user, from the
1510+
// OAuth `data:write` scope: full CRUD, still bounded by the delegating
1511+
// user's own permissions. The better-auth managed identity tables stay
1512+
// read-only ("user and sign-in data" in the copy).
14881513
description:
1489-
'Read+write ceiling for an AI agent acting on behalf of a user (OAuth `data:write`). ' +
1490-
'Full CRUD, still bounded by the delegating user\'s own permissions. ' +
1491-
'Identity tables stay read-only (better-auth managed).',
1514+
'AI assistants a user connects with read and write access: they read and change what that user can, except user and sign-in data.',
14921515
objects: {
14931516
'*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
14941517
// Even a write-scoped agent must not mutate better-auth identity tables
@@ -1500,9 +1523,11 @@ const baseDefaultPermissionSets: PermissionSet[] = [
15001523
PermissionSetSchema.parse({
15011524
name: MCP_AGENT_PERMISSION_SET_RESTRICTED,
15021525
label: 'MCP Agent — No Data Access',
1526+
// The no-object-access floor for an agent with no data scope (for example
1527+
// `actions:execute` only). It keeps the resolved set list non-empty so
1528+
// enforcement fails CLOSED, never open.
15031529
description:
1504-
'No-object-access floor for an agent with no data scope (e.g. `actions:execute` only). ' +
1505-
'Keeps the resolved set list non-empty so enforcement fails CLOSED, never open.',
1530+
'AI assistants a user connects without data access: they can run the actions they were allowed, but read or change no record.',
15061531
objects: {},
15071532
}),
15081533
];
@@ -1534,6 +1559,12 @@ const baseDefaultPermissionSets: PermissionSet[] = [
15341559
function deriveWallLessOrgAdmin(base: PermissionSet): PermissionSet {
15351560
// [ADR-0105 D4 / finding F2] The description is admin copy on Setup's
15361561
// Permission Sets page, so the decision ids stay here, not in the string.
1562+
// The engineering reading: organization administration WITHOUT blanket
1563+
// record visibility, granted instead of `organization_admin` when the tenancy
1564+
// posture enforces no organization wall, where nothing would bound the
1565+
// superuser bits. Ownership, sharing and business RLS still apply; grant
1566+
// `admin_full_access` (or an explicit set carrying the bits) when a
1567+
// deployment-wide data superuser is genuinely intended.
15371568
const objects: Record<string, any> = { ...(base.objects ?? {}) };
15381569
const { viewAllRecords: _v, modifyAllRecords: _m, ...wildcardWithoutBypass } =
15391570
(objects['*'] ?? {}) as Record<string, unknown>;
@@ -1543,11 +1574,7 @@ function deriveWallLessOrgAdmin(base: PermissionSet): PermissionSet {
15431574
name: ORGANIZATION_ADMIN_NO_BYPASS,
15441575
label: 'Organization Administrator (no record bypass)',
15451576
description:
1546-
'Organization administration WITHOUT blanket record visibility. Granted instead of ' +
1547-
'`organization_admin` when the tenancy posture enforces no organization wall, where nothing ' +
1548-
'would bound the superuser bits. Ownership, sharing and business ' +
1549-
'RLS still apply; grant `admin_full_access` (or an explicit set carrying the bits) when a ' +
1550-
'deployment-wide data superuser is genuinely intended.',
1577+
'Organization admins on a deployment with no wall between organizations: the same administration, but only the records their ownership and sharing open.',
15511578
objects,
15521579
});
15531580
}

0 commit comments

Comments
 (0)