Skip to content

Commit a8b44d4

Browse files
fix(platform-objects): a team record no longer lists Sessions with "Sign out other devices" (#24881)
Fixes #24765 Clause-②: no (narrowing) Developer run for the `domain:engine` seat 1 dispatch (claim comment 6289444669), session `session_01NftZtMY2EJSyAjctCSfkis`. ## What changes `sys_session.active_team_id` is a lookup to `sys_team`. It did not set `relatedList: false`, so every team record derived a **Sessions** related list (Setup → Teams → a team → Related). That list carried the session object's only `list_toolbar` action, "Sign out other devices". Its confirmation reads "Sign out of every other device where you're currently logged in?", and it revokes the **caller's** own other sessions, not the team's. - `packages/platform-objects/src/identity/sys-session.object.ts`: `active_team_id` now sets `relatedList: false`, with a one-line comment saying why. The field, its lookup, its label and its column stay. better-auth still writes `activeTeamId` there (`plugin-auth/src/auth-schema-config.ts`). - `packages/platform-objects/src/identity/sys-team-derived-related-lists.test.ts` (new): the pins below. - `.changeset/24765-team-sessions-related-list.md`: `minor` for `@objectstack/platform-objects` (fix round 1: a narrowing ships `minor` in the launch window, as Check Changeset's level axis enforces), with the BREAKING banner and the ADR-0087 disposition `not-required (no-migration-prescription)` that `check-adr-0087-registration` asks of a `(narrowing)` arm. ## The read, done before the edit (base `abd5acdaf7`) - **How a team's related lists are derived.** `sys_team` declares no record page anywhere in the repo (only `sys_organization` and `sys_user` have pages), so the console derives the team page's related lists. `FieldSchema.relatedList` says `false` suppresses the child and absent shows it. The key is `live` in `packages/spec/liveness/field.json`, whose evidence is objectui `deriveRelatedLists` skipping the foreign key when `relatedList` is `false`. - **Every lookup into `sys_team`** (all `lookup` / `master_detail` fields in `packages/` and `examples/`): `sys_session.active_team_id` (fixed here), `sys_team_member.team_id` (the team's Members list, which is wanted) and `sys_invitation.team_id`. The Invitations list on a team is named, not changed. Its toolbar action `invite_user` is org-scoped and not self-scoped. Its `accept_invitation` / `reject_invitation` row actions are visible only to the invitee (`record.email == ctx.user.email`). - **Who reads a team's Sessions list today:** nobody. No page, test, doc, `qa/` file or `docs/qa/platform-checklist/` item names it. `identity-auth.json`'s revoke-other-sessions item is about a user's own sessions and is unchanged. - **The user's own Sessions list** is a curated `record:related_list` tab on `SysUserDetailPage`, keyed on `sys_session.user_id`, and does not go through `active_team_id`. ## Pins In `sys-team-derived-related-lists.test.ts`, applying the spec's derivation rule to every object this package exports: - the derived related lists of `sys_team` exclude `sys_session` and still include `sys_team_member.team_id` (a guard against over-applying the key); - `sys_session.active_team_id` is still a `lookup` to `sys_team` labelled "Active Team", with `relatedList: false`; - `SysUserDetailPage` still lists `sys_session` by `user_id`; `sys_session.user_id` still derives a list on `sys_user`; `revoke_my_other_sessions` is still a `list_toolbar` action. **Ablation**, run through `scripts/ablation-replace.mjs` on the committed fix. The anchor `relatedList: false,` went 1 → 0 on disk and the blob went `adf2c552a667` → `55e8613a1c8f`. The file then ran **2 failed | 3 passed**: `exclude sys_session` and the declaration pin went red. Restored: blob == HEAD `adf2c552a667`, and `git diff HEAD` is empty. The tests import `src/` directly, so no `dist/` leg applies. **Pin sweep:** I swept `active_team_id`, "Active Team", `revoke_my_other_sessions`, "Sign out other devices" and the confirmation text across `packages/`, `qa/`, `docs/qa/platform-checklist/` and `content/docs/`. No existing pin moves. ## Verification Run at `4f4bb1fab`, which is the branch merged with `origin/main` `3fbef41b4`, unless a line says otherwise. - `pnpm --filter @objectstack/platform-objects typecheck`: exit 0. The first run went red on two type errors in the new test file, which I fixed. I did not add a ledger entry. - `pnpm --filter @objectstack/platform-objects test`: exit 0, 80 files, 1204 tests. - `pnpm --filter @objectstack/core test:repo`: exit 0, 6 files, 82 tests (pre-merge head). - Dependent suites, pre-merge head: `plugin-auth` exit 0 (3240 passed, 10 skipped) · `plugin-security` exit 0 (4047 passed, 45 skipped) · `runtime` exit 0 (6782 passed, 19 skipped) · `rest` exit 0 (6144 passed, 326 skipped) · `verify` exit 0 (376 passed). The full `cli` unit layer runs longer than the 10-minute foreground cap, so I left it to CI. Its tests that read identity objects (`organization-ownership-inventory`, `unmanaged-tables`, `src/lint`) ran: exit 0, 94 passed. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` with no paths derived 66 commands, and I ran all 66. 65 exited 0 on the first pass. `pnpm check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: no `dist/`). After `pnpm build` it exited 0. `--ran` reconciliation: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN, with every line carrying its exit code. After the merge I re-ran `check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset`, `check:i18n`, `check:type-check-debt` and `check:nul-bytes`: all exit 0. - `node scripts/check-i18n-bundles.mjs`: exit 0. All bundles are in sync, so this change regenerates no translation bundle. ## Acceptance notes - **Not asked, not built:** a team-scoped session action, and any change to `revoke_session` or to the header action. - This narrowing owes a `CONTRACT_REVIEW_TIER` review of record before the queue, run by the seat. --- _Generated by [Claude Code](https://claude.ai/code/session_01NftZtMY2EJSyAjctCSfkis)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fcb40ad commit a8b44d4

3 files changed

Lines changed: 111 additions & 0 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/platform-objects': minor
3+
---
4+
5+
fix(platform-objects): a team record no longer lists Sessions with "Sign out other devices" (#24765)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) One shipped object field gains `relatedList: false`, a key it was always free to set. No spec key, authorable spelling, export, type or stored shape is added, removed or renamed, and no stored row or column is read or rewritten, so `objectstack migrate meta` has nothing to rewrite. What narrows is one derived related list on a team's served detail page. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this field (not already-registered); and the change is metadata on a shipped object, not an interface, a type or an export (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
10+
11+
**BREAKING** for a reader of a team's derived related lists only: Setup → Teams → a team → Related no longer lists *Sessions*. Shipped as `minor` under the launch-window convention for accept-set narrowings.
12+
13+
- **Before.** `sys_session.active_team_id` is a lookup to `sys_team` that did not set `relatedList: false`, so every team record derived a Sessions related list. That list carried the session list's only toolbar action, "Sign out other devices", whose confirmation reads "Sign out of every other device where you're currently logged in?". The action signs out the administrator's own other sessions, not the team's.
14+
- **Now.** `active_team_id` sets `relatedList: false`, so a team record derives no Sessions list. A team's Members and Invitations lists are unchanged.
15+
- **Unchanged.** The `active_team_id` field, its lookup to `sys_team`, its label and its column: better-auth still writes `activeTeamId` there. A user record's Sessions tab (keyed on `user_id`), where "Sign out other devices" belongs, and the action itself.
16+
17+
**What to do.** Nothing. No team-scoped session action existed to lose.

‎packages/platform-objects/src/identity/sys-session.object.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,8 @@ export const SysSession = ObjectSchema.create({
234234
label: 'Active Team',
235235
required: false,
236236
group: 'Context',
237+
// A team record derives no Sessions list: its only header action signs out the caller's own devices.
238+
relatedList: false,
237239
}),
238240

239241
// ── Client fingerprint ───────────────────────────────────────
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A team record's page lists no Sessions; a user's page still does.
5+
*
6+
* `sys_team` declares no record page, so its related lists are DERIVED: every
7+
* `lookup` / `master_detail` field whose `reference` is `sys_team` becomes a
8+
* related list on the team's detail page unless the field sets
9+
* `relatedList: false` (`FieldSchema.relatedList`; the reader is objectui's
10+
* `deriveRelatedLists`, cited in `packages/spec/liveness/field.json`).
11+
*
12+
* `sys_session.active_team_id` used to derive one. That list carried the
13+
* session object's only `list_toolbar` action, "Sign out other devices",
14+
* which revokes the CALLER's own other sessions — on a team record it read as
15+
* a team action and was not one. The field, its lookup and its label stay
16+
* (better-auth writes `activeTeamId` into it); only the derived list goes.
17+
*
18+
* The user's own Sessions list is a curated tab on `SysUserDetailPage`, keyed
19+
* on `sys_session.user_id`, and is where that action belongs: pinned unchanged.
20+
*/
21+
22+
import { describe, expect, it } from 'vitest';
23+
import * as PlatformObjects from '../index.js';
24+
import { SysSession } from './sys-session.object.js';
25+
import { SysUserDetailPage } from '../pages/sys-user.page.js';
26+
27+
type FieldDef = { type?: string; reference?: string; relatedList?: boolean | 'primary'; label?: string };
28+
type ActionDef = { name?: string; locations?: string[] };
29+
type ObjectDef = { name?: string; fields?: Record<string, FieldDef>; actions?: ActionDef[] };
30+
31+
const CATALOGUE: ObjectDef[] = [
32+
...new Set(
33+
(Object.values(PlatformObjects) as unknown[]).filter(
34+
(v): v is ObjectDef =>
35+
!!v && typeof v === 'object' && typeof (v as ObjectDef).name === 'string' && !!(v as ObjectDef).fields,
36+
),
37+
),
38+
];
39+
40+
/** `<child>.<field>` for every relationship field that derives a related list on `parent`. */
41+
function derivedRelatedLists(parent: string): string[] {
42+
const out: string[] = [];
43+
for (const obj of CATALOGUE) {
44+
for (const [name, field] of Object.entries(obj.fields ?? {})) {
45+
if (field.type !== 'lookup' && field.type !== 'master_detail') continue;
46+
if (field.reference !== parent || field.relatedList === false) continue;
47+
out.push(`${obj.name}.${name}`);
48+
}
49+
}
50+
return out.sort();
51+
}
52+
53+
describe('sys_team derived related lists (Setup → Teams → a team → Related)', () => {
54+
it('exclude sys_session', () => {
55+
const lists = derivedRelatedLists('sys_team');
56+
expect(lists.filter((l) => l.startsWith('sys_session.'))).toEqual([]);
57+
// Guard against over-applying: the team's members are still listed.
58+
expect(lists).toContain('sys_team_member.team_id');
59+
});
60+
61+
it('keep sys_session.active_team_id as a lookup to sys_team, suppressed only as a related list', () => {
62+
const field = SysSession.fields.active_team_id as FieldDef;
63+
expect(field.type).toBe('lookup');
64+
expect(field.reference).toBe('sys_team');
65+
expect(field.label).toBe('Active Team');
66+
expect(field.relatedList).toBe(false);
67+
});
68+
});
69+
70+
describe("the user's own Sessions related list is unchanged", () => {
71+
it('SysUserDetailPage lists sys_session by user_id', () => {
72+
const lists: Array<{ objectName?: string; relationshipField?: string }> = [];
73+
const walk = (node: unknown): void => {
74+
if (Array.isArray(node)) return node.forEach(walk);
75+
if (!node || typeof node !== 'object') return;
76+
const n = node as { type?: string; properties?: { objectName?: string; relationshipField?: string } };
77+
if (n.type === 'record:related_list' && n.properties) lists.push(n.properties);
78+
Object.values(node).forEach(walk);
79+
};
80+
walk(SysUserDetailPage);
81+
expect(lists).toContainEqual(expect.objectContaining({ objectName: 'sys_session', relationshipField: 'user_id' }));
82+
});
83+
84+
it('sys_session.user_id still derives a related list on sys_user', () => {
85+
expect(derivedRelatedLists('sys_user')).toContain('sys_session.user_id');
86+
});
87+
88+
it('"Sign out other devices" stays the session list toolbar action', () => {
89+
const action = (SysSession.actions as ActionDef[]).find((a) => a.name === 'revoke_my_other_sessions');
90+
expect(action?.locations).toEqual(['list_toolbar']);
91+
});
92+
});

0 commit comments

Comments
 (0)