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
13 changes: 13 additions & 0 deletions .changeset/sys-user-role-prose-retired-action.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/platform-objects": patch
---

`sys_user.role`'s field description and its `readonly` comment stop pointing at the retired Set Platform Role action (#15188)

Both strings named `set_user_role` / "Set Platform Role", an action retired in #9968 — the description told an operator to press a button that no longer exists anywhere in the product. This is not a source comment: a field `description` is authored data that ships in the published bundle and is extracted into the i18n bundles, so it surfaces in the admin UI's field help and in generated reference material. The correct path was already there and already the only one: platform-admin standing comes from an unscoped `admin_full_access` grant in `sys_user_permission_set` (ADR-0068 D2), which is exactly what the #9968 removal note in the same file says.

- **`description`** now reads "Legacy better-auth role scalar (admin, user, …). ObjectStack no longer writes it (ADR-0068 D2) — grant platform-admin standing with an unscoped `admin_full_access` assignment in `sys_user_permission_set`." It states what the column IS (a vendor authentication-layer scalar that stays published as `user.role`) and where the operator actually goes, and it deliberately does not claim the scalar confers nothing: `judgePlatformAdmin` still reads `user.role === 'admin'` as the legacy fallback it has always been, so a pre-D2 deployment carrying the value is not locked out. Saying "this field grants nothing" would have replaced one false sentence with another.
- **The `readonly` comment** keeps its ADR-0092 anchor and now states the true reason the field is not editable — nothing writes it since #9968 — instead of naming a writer that is gone.
- **`en.objects.generated.ts`** follows by regeneration (`pnpm i18n:extract`), not by hand: the default locale's leaves are rewritten from the source on every run.

**Deliberately unchanged, and pinned so it stays that way.** The same file carries a third mention inside the #9968 removal note — *"a working \"Set Platform Role\" button **was** a supported, one-user-at-a-time resurrection channel…"*. It is past tense, it narrates what was removed, and it is true; sweeping it up with the other two would turn a true sentence false. A new test pins the removal note's tombstone opener and that past-tense sentence as occurrence counts over the source text, so both directions fail: deleting the history drops a count to 0, and re-introducing the retired action's name in live prose pushes one past 1.
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
role: {
label: "Platform Role",
help: "Platform-level role (admin, user, …). Set via the Set Platform Role action."
help: "Legacy better-auth role scalar (admin, user, …). ObjectStack no longer writes it (ADR-0068 D2) — grant platform-admin standing with an unscoped `admin_full_access` assignment in `sys_user_permission_set`."
},
banned: {
label: "Banned",
Expand Down
5 changes: 3 additions & 2 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,10 +611,11 @@ export const SysUser = ObjectSchema.create({
role: Field.text({
label: 'Platform Role',
required: false,
readonly: true, // ADR-0092 — set via the Set Platform Role action, never the edit form
readonly: true, // ADR-0092 — never the edit form; no writer since #9968 (platform admin: `sys_user_permission_set` / `admin_full_access`)
maxLength: 64,
group: 'Admin',
description: 'Platform-level role (admin, user, …). Set via the Set Platform Role action.',
description:
'Legacy better-auth role scalar (admin, user, …). ObjectStack no longer writes it (ADR-0068 D2) — grant platform-admin standing with an unscoped `admin_full_access` assignment in `sys_user_permission_set`.',
}),

banned: Field.boolean({
Expand Down
46 changes: 46 additions & 0 deletions packages/platform-objects/src/platform-objects.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
SysAccount,
Expand Down Expand Up @@ -273,6 +275,50 @@ describe('@objectstack/platform-objects', () => {
expect(actionNames, `${name} must survive the set_user_role retirement`).toContain(name);
}
});

it('#15188 — the retired action survives only as HISTORY; no live sys_user prose points at it', () => {
// Two opposite dispositions inside one file, and the trap is treating
// them as one batch. The #9968 removal note is a TOMBSTONE written in
// the PAST tense ("…WAS a supported, one-user-at-a-time…"): it narrates
// what was removed and is true, so "correcting" it turns a true
// sentence false. The `role` field's description and its `readonly`
// comment were in the PRESENT tense, telling an operator to press a
// button retired in #9968 — those are the stale ones, and the field
// description surfaces in the admin UI and the i18n bundles.
//
// Pinned as OCCURRENCE COUNTS over the source text so both directions
// red: a search-and-replace that sweeps the history sentence away
// drops a count to 0, and re-introducing the retired action's name in
// live prose pushes one past 1.
const source = readFileSync(resolve(__dirname, 'identity/sys-user.object.ts'), 'utf8');

// History — must still be there, verbatim and past-tense.
expect(
source.split('`set_user_role` (target: /api/v1/auth/admin/set-role) retired').length - 1,
'the #9968 removal note must survive as a tombstone',
).toBe(1);
expect(
source.split('was a supported, one-user-at-a-time').length - 1,
'the past-tense history sentence inside the removal note must survive unchanged',
).toBe(1);

// Live prose — the retired names appear ONLY inside that removal note.
expect(
source.split('Set Platform Role').length - 1,
'"Set Platform Role" may appear only in the removal note',
).toBe(1);
expect(
source.split('set_user_role').length - 1,
'`set_user_role` may appear only in the removal note',
).toBe(1);

// …and the field points at the route that exists (ADR-0068 D2).
const role = SysUser.fields.role as { description?: unknown };
expect(typeof role.description).toBe('string');
expect(role.description).not.toContain('Set Platform Role');
expect(role.description).toContain('sys_user_permission_set');
expect(role.description).toContain('admin_full_access');
});
});

describe('data portability — derived, not declared (#3025 / #3543)', () => {
Expand Down
Loading