Repository navigation
Expand file tree
/
Copy pathplatform-objects.test.ts
More file actions
730 lines (675 loc) · 39.7 KB
/
Copy pathplatform-objects.test.ts
File metadata and controls
730 lines (675 loc) · 39.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
// 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,
SysApiKey,
SysBusinessUnit,
SysBusinessUnitMember,
SysDeviceCode,
SysInvitation,
SysJwks,
SysMember,
SysOauthAccessToken,
SysOauthApplication,
SysOauthRefreshToken,
SysOrganization,
SysSession,
SysSsoProvider,
SysTeam,
SysTeamMember,
SysTwoFactor,
SysUser,
SysUserPreference,
SysVerification,
} from './identity/index.js';
// RBAC objects (SysRole/SysPermissionSet/… + defaultPermissionSets) moved to
// @objectstack/plugin-security and the sharing objects to
// @objectstack/plugin-sharing per ADR-0029 K2 — see their packages' tests.
// sys_audit_log / sys_activity / sys_comment / sys_attachment moved to
// @objectstack/plugin-audit and sys_presence to @objectstack/service-realtime
// per ADR-0029 K2 — see their packages' tests.
// sys_webhook moved to @objectstack/plugin-webhooks per ADR-0029 (K2.a).
import {
SysMetadata,
SysMetadataHistoryObject,
} from './metadata/index.js';
import { SysSecret, SysSetting } from './system/index.js';
import { ACCOUNT_APP, SETUP_APP, SETUP_NAV_CONTRIBUTIONS, STUDIO_APP } from './apps/index.js';
import { AppSchema } from '@objectstack/spec/ui';
import { resolveEffectiveApiMethods, isApiOperationAllowed, canServeApiOperation } from '@objectstack/spec/data';
const systemObjects = [
['SysUser', SysUser, 'sys_user'],
['SysSession', SysSession, 'sys_session'],
['SysAccount', SysAccount, 'sys_account'],
['SysVerification', SysVerification, 'sys_verification'],
['SysOrganization', SysOrganization, 'sys_organization'],
['SysMember', SysMember, 'sys_member'],
['SysInvitation', SysInvitation, 'sys_invitation'],
['SysTeam', SysTeam, 'sys_team'],
['SysTeamMember', SysTeamMember, 'sys_team_member'],
['SysApiKey', SysApiKey, 'sys_api_key'],
['SysTwoFactor', SysTwoFactor, 'sys_two_factor'],
['SysUserPreference', SysUserPreference, 'sys_user_preference'],
['SysMetadata', SysMetadata, 'sys_metadata'],
['SysMetadataHistoryObject', SysMetadataHistoryObject, 'sys_metadata_history'],
['SysSetting', SysSetting, 'sys_setting'],
] as const;
describe('@objectstack/platform-objects', () => {
it.each(systemObjects)('%s uses a canonical sys_ short name', (_exportName, object, name) => {
expect(object.name).toBe(name);
});
it.each(systemObjects)('%s is marked as a system object', (_exportName, object) => {
expect(object.isSystem).toBe(true);
});
it.each(systemObjects)('%s does not use deprecated storage identity fields', (_exportName, object) => {
expect((object as any).namespace).toBeUndefined();
expect((object as any).tableName).toBeUndefined();
});
describe('no shipped object declares a `password`-typed field (ADR-0100)', () => {
// The generic read path masks `password` fields to SECRET_MASK; better-auth
// objects are exempted so login reads see the stored value
// (collectMaskedReadFields). That exemption is a safety net, not a crutch:
// no identity object today even declares a `password`-typed field —
// sys_account.password is a hashed `text` column. If anyone retypes it to
// `password`, masking would apply through the better-auth adapter and silently
// break login; this pin fails first so that change is a deliberate decision.
it.each(systemObjects)('%s has no field of type `password`', (_exportName, object) => {
const fields = ((object as any).fields ?? {}) as Record<string, { type?: string }>;
const passwordFields = Object.entries(fields)
.filter(([, def]) => def?.type === 'password')
.map(([name]) => name);
expect(passwordFields).toEqual([]);
});
it('sys_account.password is a hashed `text` column, not a `password` field', () => {
expect((SysAccount as any).fields?.password?.type).toBe('text');
});
});
describe('secure-by-default posture (ADR-0066 ④)', () => {
// Raw secret / live-credential stores are opted OUT of the wildcard `'*'`
// grant: only an explicit per-object grant or the posture-gated superuser
// bypass (viewAllRecords/modifyAllRecords) reaches them. These assertions
// PIN the rollout — dropping the flag from any of these objects silently
// re-exposes signing keys / reset tokens / bearer credentials to every
// authenticated member, so a removal must fail here first (ADR-0078:
// no silent regression to the inert/exposed state).
const privateObjects = [
['SysSecret', SysSecret], // encrypted settings/datasource secrets
['SysJwks', SysJwks], // JWT signing private keys
['SysVerification', SysVerification], // password-reset / verify tokens
['SysOauthAccessToken', SysOauthAccessToken], // live bearer tokens
['SysOauthRefreshToken', SysOauthRefreshToken], // live refresh tokens
['SysDeviceCode', SysDeviceCode], // pending device-grant codes
] as const;
it.each(privateObjects)('%s declares access.default = private', (_exportName, object) => {
expect((object as any).access?.default).toBe('private');
});
it('SysSsoProvider is capability-gated (ADR-0066 D3)', () => {
// Admin config with an embedded live credential (oidc_client_secret) —
// the D3 capability gate (not the private posture) is the sso pattern.
// This pin lived on SysScimProvider until that object retired (commit 4d25d22d4);
// it moves to the surviving sibling so the gate cannot be dropped
// silently.
expect((SysSsoProvider as any).requiredPermissions).toEqual(['manage_platform_settings']);
});
it('member self-service objects deliberately stay on the public posture', () => {
// Account app ("My Sessions" / "My API Keys" / "My Apps") and the 2FA
// "My Enrollment" view read these via the generic data layer with a
// MEMBER context — flipping them private would break self-service.
// Row scoping (owner/tenant RLS + _self carve-outs) is their guard.
for (const object of [SysSession, SysApiKey, SysOauthApplication, SysTwoFactor]) {
expect((object as any).access?.default).not.toBe('private');
}
});
});
describe('sysadmin row actions', () => {
// Setup-App admins must be able to drive the access-control lifecycle
// without dropping to SQL. These assertions lock in the high-traffic
// affordances (activate/deactivate/clone for RBAC objects; unlink
// for identity links) so they cannot silently regress.
it('SysAccount exposes an unlink-account row action wired to better-auth', () => {
const unlink = (SysAccount.actions ?? []).find((a) => a.name === 'unlink_account');
expect(unlink).toBeDefined();
expect(unlink?.target).toBe('/api/v1/auth/unlink-account');
// better-auth 1.7 narrowed the body to `{ accountId }`, where accountId
// is the account ROW id — the old `{ providerId, accountId }` pair (in
// which accountId meant the provider's id for the user) is gone.
const params = unlink?.params ?? [];
expect(params.map((p) => p.name)).toEqual(['accountId']);
expect(params[0]?.field).toBe('id');
});
it('SysOauthApplication routes all mutations through better-auth, not the data layer', () => {
const actions = SysOauthApplication.actions ?? [];
const names = actions.map((a) => a.name).sort();
expect(names).toEqual([
'create_oauth_application',
'delete_oauth_application',
'disable_oauth_application',
'enable_oauth_application',
'rotate_client_secret',
]);
const create = actions.find((a) => a.name === 'create_oauth_application');
expect(create?.target).toBe('/api/v1/auth/sys-oauth-application/register');
expect(create?.method).toBe('POST');
expect(create?.mode).toBe('create');
expect(create?.resultDialog?.fields?.map((f) => f.path)).toEqual([
'client.client_id',
'client.client_secret',
]);
const rotate = actions.find((a) => a.name === 'rotate_client_secret');
const del = actions.find((a) => a.name === 'delete_oauth_application');
const disable = actions.find((a) => a.name === 'disable_oauth_application');
const enable = actions.find((a) => a.name === 'enable_oauth_application');
expect(rotate?.target).toBe('/api/v1/auth/oauth2/client/rotate-secret');
expect(rotate?.method).toBe('POST');
expect((rotate?.params ?? []).map((p) => p.field)).toEqual(['client_id']);
expect(del?.target).toBe('/api/v1/auth/oauth2/delete-client');
expect(del?.method).toBe('POST');
expect(del?.mode).toBe('delete');
// Enable/disable both hit the ObjectStack-added bridge route on
// /api/v1/auth. Re-measured 2026-08-23 against the installed
// @better-auth/oauth-provider@1.7.1: the stock admin endpoint's Zod
// body schema (`dist/authorize-Crqw4_bR.mjs:2862-2889`) still does
// not accept `disabled`, so the bridge route stays warranted. They
// differ only in the static `disabled` body field and the
// visibility predicate, so exactly one is active at any time. Both lead
// with the platform-admin standing the bridge's gate judges.
expect(disable?.target).toBe('/api/v1/auth/admin/oauth2/toggle-disabled');
expect(disable?.bodyExtra).toEqual({ disabled: true });
expect((disable?.visible as any)?.source).toBe('(current_user.isPlatformAdmin == true && has(record.disabled) && record.disabled != true) && features.oidcProvider != false');
expect(enable?.target).toBe('/api/v1/auth/admin/oauth2/toggle-disabled');
expect(enable?.bodyExtra).toEqual({ disabled: false });
expect((enable?.visible as any)?.source).toBe('(current_user.isPlatformAdmin == true && has(record.disabled) && record.disabled == true) && features.oidcProvider != false');
// Generic CRUD must NOT expose mutating methods — all writes are
// reserved for better-auth wrappers above so OAuth-specific
// invariants (token revocation on delete, consent invalidation,
// disabled checks at runtime) always run.
expect(SysOauthApplication.enable?.apiMethods).not.toContain('delete');
expect(SysOauthApplication.enable?.apiMethods).not.toContain('update');
expect(SysOauthApplication.enable?.apiMethods).not.toContain('create');
});
it('SysSsoProvider request_domain_verification resultDialog paths address the inner data payload (no `data.` prefix)', () => {
// The console action runtime unwraps the `{ success, data }` envelope
// before resolving resultDialog field paths (same as create_user,
// two-factor and OAuth). These paths must therefore be relative to the
// INNER data — a `data.` prefix double-nests and blanks the dialog.
// Regression guard for the "temporary password / DNS record shows empty"
// class of bug.
const action = (SysSsoProvider.actions ?? []).find(
(a) => a.target === '/api/v1/auth/admin/sso/request-domain-verification',
);
expect(action?.resultDialog?.fields?.map((f) => f.path)).toEqual([
'dnsRecordType',
'dnsRecordName',
'dnsRecordValue',
]);
});
it('SysUser admin actions surface on the record-detail header, not just the row menu', () => {
// An admin viewing an open user record must be able to run the same
// account-management actions available from the Users list row menu —
// otherwise they have to navigate back to the list. Each admin action is
// therefore declared on BOTH `list_item` and `record_header` (the detail
// header overflows extras into the ⋯ "More" menu). `record_header` is the
// only detail-surface location objectui consumes (it does NOT read
// `record_more`), so these must use `record_header` specifically.
const adminActions = ['ban_user', 'unban_user', 'unlock_user', 'set_user_password', 'impersonate_user'];
for (const name of adminActions) {
const a = (SysUser.actions ?? []).find((x) => x.name === name);
expect(a, `${name} action must exist`).toBeTruthy();
expect(a?.locations, `${name} locations`).toContain('list_item');
expect(a?.locations, `${name} must also surface on the detail header`).toContain('record_header');
}
});
it('#9968 — set_user_role is retired from sys_user, every sibling admin action survives', () => {
// set_user_role's only effect was internalAdapter.updateUser(userId,
// { role }) — a gated UI writer for the legacy sys_user.role scalar
// ADR-0068 D2 stopped synthesizing (platform-admin membership moved to
// sys_user_permission_set / admin_full_access). Removal, not a
// narrowed re-implementation (maintainer ruling, 2026-08-20/2026-08-22,
// Option B). Pinned in one test with its counter-direction so a
// retirement that removed the WRONG entry (or several) cannot pass:
// the retired name must be gone by name, and every sibling survivor
// must still be present by name.
const actionNames = (SysUser.actions ?? []).map((a) => a.name);
expect(actionNames, 'set_user_role must be gone').not.toContain('set_user_role');
const survivors = [
'invite_user',
'ban_user',
'unban_user',
'unlock_user',
'create_user',
'set_user_password',
'impersonate_user',
'update_my_profile',
'change_my_password',
'change_my_email',
'resend_verification_email',
'delete_my_account',
'enable_two_factor',
'disable_two_factor',
'generate_backup_codes',
];
for (const name of survivors) {
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');
// #19875 — which route exists depends on the tenancy posture. Under a
// walled posture (`group` / `isolated`) the unscoped grant confers no
// platform-admin standing (#11663 L5), so the text must name the anchor
// that confers on EVERY posture and keep the grant clause `single`-only;
// an unqualified grant prescription silently does nothing on a walled rig.
expect(role.description).toContain('OS_PLATFORM_OWNER_EMAIL');
expect(role.description).toContain('`single`');
});
it('SysSession revoke_session identifies its session by the row id, resolved server-side', () => {
const revoke = (SysSession.actions ?? []).find((a) => a.name === 'revoke_session');
expect(revoke).toBeDefined();
expect(revoke?.type).toBe('api');
expect(revoke?.locations).toEqual(['list_item']);
expect(revoke?.target).toBe('/api/v1/auth/sys-session/revoke');
expect(revoke?.recordIdParam).toBe('sessionId');
// The row field that seeds `recordIdParam` defaults to `id` — the one
// column every list row carries.
expect(revoke?.recordIdField ?? 'id').toBe('id');
expect(SysSession.fields.id).toBeDefined();
});
it('SysSession keeps its session token internal: no read path serves it', () => {
const token = SysSession.fields.token as { internal?: unknown; hidden?: unknown; readonly?: unknown };
expect(token.internal).toBe(true);
expect(token.hidden).toBe(true);
expect(token.readonly).toBe(true);
});
it('no identity action identifies its record by a field the server never serves', () => {
// An action seeds `recordIdParam` from the ROW the console holds. An
// `internal` field is omitted from every read, so it can never be on that
// row, and an action keyed on one can never name its record.
const objects = [
SysAccount, SysApiKey, SysBusinessUnit, SysBusinessUnitMember, SysDeviceCode,
SysInvitation, SysJwks, SysMember, SysOauthAccessToken, SysOauthApplication,
SysOauthRefreshToken, SysOrganization, SysSession, SysSsoProvider, SysTeam,
SysTeamMember, SysTwoFactor, SysUser, SysUserPreference, SysVerification,
];
const offenders: string[] = [];
let seeded = 0;
for (const object of objects) {
for (const action of object.actions ?? []) {
if (!action.recordIdParam && !action.recordIdField) continue;
seeded += 1;
const field = action.recordIdField ?? 'id';
const declared = (object.fields as Record<string, { internal?: unknown } | undefined>)[field];
if (field !== 'id' && !declared) offenders.push(`${object.name}.${action.name}: '${field}' is not a field`);
if (declared?.internal === true) offenders.push(`${object.name}.${action.name}: '${field}' is internal`);
}
}
// Positive control: the sweep reaches real declarations.
expect(seeded).toBeGreaterThan(0);
expect(offenders).toEqual([]);
});
});
describe('data portability — derived, not declared (#3025 / #3543)', () => {
it('sys_business_unit is default-open and the derivation grants import/export', () => {
// The Business Units list exposes Import/Export buttons for HRIS batch
// sync (#3025). Since the enum shrink (#3543) neither verb is declarable:
// the object carries NO whitelist (default-open) and the REST gate
// derives import (⊆ create∨update) and export (⊆ list) from the
// effective set. Regression guard: the whitelist must stay ABSENT — a
// whitelist naming all six primitives is equivalent but stops tracking
// future primitives, and any narrower whitelist would 405 the buttons.
expect(SysBusinessUnit.enable?.apiMethods).toBeUndefined();
const eff = resolveEffectiveApiMethods(SysBusinessUnit.enable);
expect(eff.mode).toBe('unrestricted');
expect(isApiOperationAllowed(eff, 'import')).toBe(true);
expect(isApiOperationAllowed(eff, 'export')).toBe(true);
expect(isApiOperationAllowed(eff, 'bulk', { bulkChild: 'create' })).toBe(true);
});
it('sys_business_unit_member is default-open and derives import/export (#3391 P0 pairing)', () => {
// HRIS org-tree sync imports TWO tables together — the units (above) AND
// their memberships. Both were reclaimed together in the #3543 audit.
expect(SysBusinessUnitMember.enable?.apiMethods).toBeUndefined();
const eff = resolveEffectiveApiMethods(SysBusinessUnitMember.enable);
expect(eff.mode).toBe('unrestricted');
expect(isApiOperationAllowed(eff, 'import')).toBe(true);
expect(isApiOperationAllowed(eff, 'export')).toBe(true);
});
});
describe('SETUP_APP (ADR-0029 D7 shell)', () => {
it('parses cleanly through AppSchema', () => {
expect(() => AppSchema.parse(SETUP_APP)).not.toThrow();
});
it('is a shell of group anchors with no enumerated objects', () => {
const nav = SETUP_APP.navigation ?? [];
expect(nav.length).toBeGreaterThan(0);
for (const item of nav) {
expect(item.type).toBe('group');
// Shell groups carry no children — entries come from contributions.
expect((item as { children?: unknown[] }).children).toEqual([]);
}
});
it('keeps the group_integrations anchor (filled by plugin-webhooks contribution)', () => {
const group = SETUP_APP.navigation?.find((n) => n.id === 'group_integrations');
expect(group).toBeDefined();
expect(group?.type).toBe('group');
// The webhooks entries are no longer static here — plugin-webhooks
// contributes them into this slot (ADR-0029 D7 / K2.a).
expect((group as { children?: unknown[] }).children).toEqual([]);
});
});
describe('platform app protection (ADR-0010 §3.7)', () => {
// All three platform apps are core UI shipped by this package: a
// tenant overlay that breaks Setup/Studio locks admins/implementers
// out of the repair surface, and Account is every user's only
// self-service security surface. The loader translates `protection`
// into the `_lock` envelope; clients (e.g. objectui's
// isNavigationSyncableApp) rely on `_lock` to skip automatic
// navigation writes into these apps.
it.each([
['SETUP_APP', SETUP_APP],
['STUDIO_APP', STUDIO_APP],
['ACCOUNT_APP', ACCOUNT_APP],
])('%s declares a full lock', (_name, app) => {
expect(app.protection?.lock).toBe('full');
expect(() => AppSchema.parse(app)).not.toThrow();
});
});
describe('SETUP_NAV_CONTRIBUTIONS (ADR-0029 D7)', () => {
const shellGroupIds = new Set(
(SETUP_APP.navigation ?? []).map((n) => n.id),
);
it('all target the setup app and an existing shell group', () => {
expect(SETUP_NAV_CONTRIBUTIONS.length).toBeGreaterThan(0);
for (const c of SETUP_NAV_CONTRIBUTIONS) {
expect(c.app).toBe('setup');
expect(c.group).toBeDefined();
expect(shellGroupIds.has(c.group!)).toBe(true);
expect(Array.isArray(c.items)).toBe(true);
expect(c.items.length).toBeGreaterThan(0);
}
});
it('does not contribute slots owned by capability plugins', () => {
// group_integrations → @objectstack/plugin-webhooks (K2.a)
// group_approvals → @objectstack/plugin-approvals (K2.b)
for (const ownedSlot of ['group_integrations', 'group_approvals']) {
const contrib = SETUP_NAV_CONTRIBUTIONS.find((c) => c.group === ownedSlot);
expect(contrib).toBeUndefined();
}
});
// ─────────────────────────────────────────────────────────────────────
// [#7544] A `type: 'object'` entry promises a browsable list. Whether the
// destination can serve one is decided by the OBJECT's own `enable` block
// — `apiEnabled: false` → 404 `OBJECT_API_DISABLED`, an `apiMethods`
// whitelist without `list` → 405 — resolved here through the same single
// derivation source `rest-server.ts` gates on (#3391), in the same order.
//
// ⚠️ That decision takes NO user and NO permissions
// (`apiAccessDenialFromEnable` is a pure function of `enable`), which is
// why a `requiredPermissions` gate on the ENTRY cannot prune an entry
// whose OBJECT is API-disabled: it is dead for every persona, platform
// admin included. `nav_jwks` → `sys_jwks` was exactly that, and is gone.
// ─────────────────────────────────────────────────────────────────────
describe('object entries can actually serve a list (#7544)', () => {
// Objects this package owns whose `enable` block is therefore importable
// here: every current Setup nav target, PLUS `sys_jwks`. Keeping the
// removed one listed is what makes the gate diagnose rather than merely
// notice — a re-added `nav_jwks` fails on "cannot serve a list", the
// actual reason, instead of falling into the unclassified branch below
// and reporting only that this file has never heard of the object.
const OWNED_NAV_OBJECTS: Record<string, { enable?: unknown }> = {
sys_account: SysAccount,
sys_api_key: SysApiKey,
sys_business_unit: SysBusinessUnit,
sys_invitation: SysInvitation,
sys_jwks: SysJwks,
sys_oauth_application: SysOauthApplication,
sys_organization: SysOrganization,
sys_session: SysSession,
sys_team: SysTeam,
sys_user: SysUser,
sys_user_preference: SysUserPreference,
};
// Targets owned by another package (contributed there, gated by
// `requiresObject`), so nothing here can read their `enable`. Named
// rather than skipped: a NEW unresolvable target must be classified by
// hand instead of silently dropping out of this gate's coverage.
const FOREIGN_NAV_OBJECTS = new Set(['sys_notification']);
const objectEntries = (): Array<{ id: string; objectName: string }> => {
const out: Array<{ id: string; objectName: string }> = [];
const walk = (items: readonly unknown[]): void => {
for (const raw of items) {
const e = raw as { id?: string; type?: string; objectName?: string; children?: unknown[] };
if (e?.type === 'object' && e.objectName) {
out.push({ id: e.id ?? '(unnamed)', objectName: e.objectName });
}
if (Array.isArray(e?.children)) walk(e.children);
}
};
for (const c of SETUP_NAV_CONTRIBUTIONS) walk(c.items);
return out;
};
/**
* The rest-server gate order, over an object's declared `enable`.
*
* [#7912] This USED to re-spell the two steps here. It no longer does:
* the order is the spec's own `canServeApiOperation`, the single export
* `filterAppForUser`'s nav prune and `os validate`'s
* `nav-object-unservable` rule both read. A local copy would let this
* gate keep passing while the server it claims to mirror had changed —
* exactly the drift #7544 asked for a single source to prevent.
*/
const canList = (enable: unknown): boolean =>
canServeApiOperation(enable as never, 'list');
it('every contributed object entry targets a listable object', () => {
const entries = objectEntries();
// Control: the walk really reads the contributions, so the assertions
// below cannot pass by vacuity.
expect(entries.length).toBeGreaterThan(0);
expect(entries.map((e) => e.id)).toContain('nav_api_keys');
const unclassified = entries
.filter((e) => !(e.objectName in OWNED_NAV_OBJECTS) && !FOREIGN_NAV_OBJECTS.has(e.objectName))
.map((e) => `${e.id} → ${e.objectName}`);
expect(
unclassified,
'new nav target this package cannot check — add it to OWNED_NAV_OBJECTS or FOREIGN_NAV_OBJECTS',
).toEqual([]);
const dead = entries
.filter((e) => e.objectName in OWNED_NAV_OBJECTS)
.filter((e) => !canList(OWNED_NAV_OBJECTS[e.objectName]!.enable))
.map((e) => `${e.id} → ${e.objectName}`);
expect(dead, 'nav entries whose destination answers 4xx on list — see #7544').toEqual([]);
});
it('nav_jwks is gone, and the nav_api_keys control still lists', () => {
const entries = objectEntries();
// The reported defect: no entry advertises the unreachable
// destination any more, under this id or any other.
expect(entries.find((e) => e.id === 'nav_jwks')).toBeUndefined();
expect(entries.some((e) => e.objectName === 'sys_jwks')).toBe(false);
// The NEGATIVE direction — the card's own control, from the same QA
// run: `nav_api_keys` → `sys_api_key` rides the same machinery and
// returns no 4xx. A fix that pruned this too would be over-reach, and
// must fail here.
expect(entries.find((e) => e.id === 'nav_api_keys')?.objectName).toBe('sys_api_key');
expect(canList(SysApiKey.enable)).toBe(true);
});
it('the repair is the nav entry — sys_jwks stays API-disabled and private', () => {
// ⛔ Opening a data-API read path onto private JWT signing-key material
// would be a credential disclosure. `apiMethods: []` fails CLOSED
// (#3391); the `private` posture is pinned above (ADR-0066 ④).
expect(SysJwks.enable?.apiEnabled).toBe(false);
expect(SysJwks.enable?.apiMethods).toEqual([]);
expect(canList(SysJwks.enable)).toBe(false);
expect((SysJwks as { access?: { default?: string } }).access?.default).toBe('private');
});
});
});
});
// #2874 P2a — behavior-equivalence lock for the requiresFeature migration.
// Every hand-written `visible: 'features.*'` gate was replaced by the
// declarative `requiresFeature` sugar; these rows pin the LOWERED predicate
// to the exact CEL string that was previously hand-written, so the migration
// is provably behavior-neutral.
//
// The RESIDUAL row predicates are no longer byte-identical to the pre-#2874
// hand-written strings, and that is #8990 rather than drift in this lowering:
// each `record.*` read is now opened by a `has()` guard, because the action
// binding is sparse (a list row carries only the view's `$select` projection)
// and an unguarded read aborts the whole predicate at key resolution — silently
// dropping the button. What this matrix still pins is what it was built to pin:
// the GATE term, its `&&` composition, and the operand order — the guards sit
// inside the parenthesized residual, and `features.*` is untouched.
//
// Two rows also changed OPERATOR, not just guard: `disable_oauth_application`
// was `!record.disabled` and `enable_oauth_application` was a bare
// `record.disabled`. Both are operators that need a bool and fault on the
// projected-null column better-auth leaves on every never-toggled application,
// so they became `!= true` / `== true` — the equality form CEL answers rather
// than faults on. See sys-oauth-application.object.ts for the measurement.
describe('feature-gate lowering matrix (#2874)', () => {
const ORG = 'features.organization != false';
const MULTI_ORG = 'features.multiOrgEnabled != false';
// The org-admin rows also carry `requiresMembershipReach`, whose lowering
// runs FIRST and puts the membership-grade term ahead of the feature gate:
// one `'<projected grade>' in current_user.positions` term per grade the
// reach table admits to the endpoint (ADR-0108 D1). Spelled out here rather
// than computed, so this matrix pins the bytes the console evaluates.
const INVITE = "('org_owner' in current_user.positions || 'org_admin' in current_user.positions || 'delegated_admin' in current_user.positions)";
const ADMINS = "('org_owner' in current_user.positions || 'org_admin' in current_user.positions)";
const OWNER = "'org_owner' in current_user.positions";
// The platform-admin standing: the authored `visible` of every action whose
// door runs the platform-admin gate — the ADR-0095 D3 PLATFORM_ADMIN rung the
// gate judges (ADR-0068 D4). Composed ahead of any feature gate. ⛔ Never a
// `current_user.positions` read. The repo-wide enumeration of that family is
// `platform-admin-affordance-standing.test.ts`; these rows pin the bytes.
const STANDING = 'current_user.isPlatformAdmin == true';
const rows: Array<[string, { actions?: readonly { name?: string; visible?: unknown; params?: readonly unknown[] }[] }, string, string]> = [
['SysOrganization', SysOrganization, 'create_organization', MULTI_ORG],
// Their doors are better-auth's `organization/update` (owner, admin) and
// `organization/delete` (owner), so the grade term leads the feature gate.
['SysOrganization', SysOrganization, 'update_organization', `${ADMINS} && ${MULTI_ORG}`],
['SysOrganization', SysOrganization, 'rename_organization', `${ADMINS} && ${MULTI_ORG}`],
['SysOrganization', SysOrganization, 'delete_organization', `(${OWNER}) && ${MULTI_ORG}`],
['SysOrganization', SysOrganization, 'set_active_organization', MULTI_ORG],
['SysOrganization', SysOrganization, 'leave_organization', MULTI_ORG],
['SysUser', SysUser, 'invite_user', `${INVITE} && ${ORG}`],
['SysUser', SysUser, 'create_user', `(${STANDING}) && features.admin == true`],
// [#11544] Third mirror of `invite_user` — the Members tab's own copy of
// the email-invite entry. Same gate as the sys_user / sys_invitation rows.
['SysMember', SysMember, 'invite_user', `${INVITE} && ${ORG}`],
// No grade term: add-member is gated on platform-admin standing, not on a
// membership grade, so the action carries no `requiresMembershipReach`.
// The standing term is the authored `visible` — the ADR-0095 D3
// PLATFORM_ADMIN rung the door's gate judges (ADR-0068 D4) — composed
// ahead of the feature gate. ⛔ Never a `current_user.positions` read.
['SysMember', SysMember, 'add_member', `(${STANDING}) && ${ORG}`],
// The rest of the platform-admin family. Three carry no feature gate, so
// the authored predicate is served as written — no lowering — and is
// pinned here anyway, beside add_member, so every member has its row.
['SysUser', SysUser, 'set_user_manager', `${STANDING} && has(record.source) && record.source != "idp_provisioned"`],
['SysSsoProvider', SysSsoProvider, 'register_sso_provider', STANDING],
['SysSsoProvider', SysSsoProvider, 'register_saml_provider', STANDING],
['SysSsoProvider', SysSsoProvider, 'request_domain_verification', STANDING],
['SysSsoProvider', SysSsoProvider, 'verify_domain', STANDING],
['SysMember', SysMember, 'update_member_role', `${ADMINS} && ${ORG}`],
['SysMember', SysMember, 'remove_member', `${ADMINS} && ${ORG}`],
['SysMember', SysMember, 'transfer_ownership', `((has(record.role) && record.role != 'owner') && ${OWNER}) && ${ORG}`],
['SysInvitation', SysInvitation, 'invite_user', `${INVITE} && ${ORG}`],
['SysInvitation', SysInvitation, 'cancel_invitation', `${ADMINS} && ${ORG}`],
['SysInvitation', SysInvitation, 'resend_invitation', `${INVITE} && ${ORG}`],
['SysTeam', SysTeam, 'create_team', `${ADMINS} && ${ORG}`],
['SysTeam', SysTeam, 'update_team', `${ADMINS} && ${ORG}`],
['SysTeam', SysTeam, 'remove_team', `${ADMINS} && ${ORG}`],
['SysTeamMember', SysTeamMember, 'add_team_member', `${ADMINS} && ${ORG}`],
['SysTeamMember', SysTeamMember, 'remove_team_member', `${ADMINS} && ${ORG}`],
// #2874 P2b — audit gates: capability-dependent actions that previously
// shipped UNGATED (rendered even with the backing plugin off, then 404'd).
// Their doors are platform-admin gated, so the standing leads.
['SysUser', SysUser, 'ban_user', `(${STANDING}) && features.admin == true`],
['SysUser', SysUser, 'unban_user', `(${STANDING}) && features.admin == true`],
['SysUser', SysUser, 'unlock_user', `(${STANDING}) && features.admin == true`],
['SysUser', SysUser, 'set_user_password', `(${STANDING}) && features.admin == true`],
['SysUser', SysUser, 'impersonate_user', `(${STANDING}) && features.admin == true`],
['SysUser', SysUser, 'enable_two_factor', '(has(record.id) && record.id == ctx.user.id && has(record.two_factor_enabled) && record.two_factor_enabled != true) && features.twoFactor == true'],
['SysUser', SysUser, 'disable_two_factor', '(has(record.id) && record.id == ctx.user.id && has(record.two_factor_enabled) && record.two_factor_enabled == true) && features.twoFactor == true'],
['SysUser', SysUser, 'generate_backup_codes', '(has(record.id) && record.id == ctx.user.id && has(record.two_factor_enabled) && record.two_factor_enabled == true) && features.twoFactor == true'],
['SysTwoFactor', SysTwoFactor, 'enable_two_factor', 'features.twoFactor == true'],
['SysTwoFactor', SysTwoFactor, 'disable_two_factor', 'features.twoFactor == true'],
['SysTwoFactor', SysTwoFactor, 'regenerate_backup_codes', 'features.twoFactor == true'],
['SysOauthApplication', SysOauthApplication, 'create_oauth_application', 'features.oidcProvider != false'],
['SysOauthApplication', SysOauthApplication, 'delete_oauth_application', 'features.oidcProvider != false'],
['SysOauthApplication', SysOauthApplication, 'disable_oauth_application', `(${STANDING} && has(record.disabled) && record.disabled != true) && features.oidcProvider != false`],
['SysOauthApplication', SysOauthApplication, 'enable_oauth_application', `(${STANDING} && has(record.disabled) && record.disabled == true) && features.oidcProvider != false`],
['SysOauthApplication', SysOauthApplication, 'rotate_client_secret', 'features.oidcProvider != false'],
];
it.each(rows)('%s.%s#%s lowers to the previous hand-written predicate', (_export, object, actionName, expected) => {
const action = (object.actions ?? []).find((a) => a.name === actionName);
expect(action, `${actionName} exists`).toBeDefined();
expect((action?.visible as { source?: string })?.source).toBe(expected);
});
// The rows above are the whole multi-org surface of sys_organization: an
// action gated on `multiOrgEnabled` without a row here is one this matrix
// never pinned. `change_slug` is not among them — its door is the cloud
// orchestrator's, and the composition that mounts it declares the action.
it('the SysOrganization rows are every action it gates on multiOrgEnabled, and change_slug is none of them', () => {
const gated = (SysOrganization.actions ?? [])
.filter((a) => ((a.visible as { source?: string } | undefined)?.source ?? '').includes('features.multiOrgEnabled'))
.map((a) => a.name);
const pinned = rows.filter(([exported]) => exported === 'SysOrganization').map(([, , name]) => name);
expect([...gated].sort()).toEqual([...pinned].sort());
expect(gated).not.toContain('change_slug');
});
it('SysUser.create_user#phoneNumber param lowers to the previous hand-written predicate', () => {
const create = (SysUser.actions ?? []).find((a) => a.name === 'create_user');
const phone = (create?.params ?? []).find((p) => (p as { name?: string }).name === 'phoneNumber');
expect(phone).toBeDefined();
expect(((phone as { visible?: { source?: string } }).visible)?.source).toBe('features.phoneNumber == true');
});
it('the requiresFeature sugar never survives into parsed objects', () => {
for (const [, object] of systemObjects) {
for (const action of object.actions ?? []) {
expect(action).not.toHaveProperty('requiresFeature');
expect(action).not.toHaveProperty('requiresMembershipReach');
for (const param of (action as { params?: readonly unknown[] }).params ?? []) {
expect(param).not.toHaveProperty('requiresFeature');
}
}
}
});
});