-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathpublic-auth-features.ts
More file actions
331 lines (318 loc) · 13.2 KB
/
Copy pathpublic-auth-features.ts
File metadata and controls
331 lines (318 loc) · 13.2 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
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import type { z } from 'zod';
/**
* # Public auth feature-flag registry (#2874)
*
* Classification registry for the **public `/api/v1/auth/config` `features`
* contract** produced by plugin-auth's `getPublicConfig()`. Every boolean flag
* served there MUST be classified here — a drift guard in plugin-auth
* (`public-feature-registry.test.ts`) asserts the served key set ≡ this
* registry's key set, so a new flag that ships unclassified turns CI red.
*
* Why: the create-user `phoneNumber` bug (#2871 / objectui#2406) was one
* instance of a broader class — *the UI advertises a capability the runtime
* doesn't have because the plugin behind it is off*. Manual per-site gating
* discipline inevitably leaves silent gaps; this registry is the single place
* where each flag's consumption surface, default semantics, and gated spec
* inputs (or exemption rationale) are recorded and CI-enforced.
*
* NOT to be confused with runtime rollout toggles — those live on the
* `feature_flags` settings manifest (ADR-0007), not in the spec (the former
* `kernel/feature.zod.ts` `FeatureFlagSchema` was removed as an orphan: zero
* runtime consumers once its dead capabilities-descriptor home went, #3605).
* This registry classifies the fixed, deployment-level capability flags that
* plugin-auth advertises to anonymous clients.
*
* Consumers:
* - `ui/action.zod.ts` — `requiresFeature` sugar on actions/params is lowered
* at parse time (see {@link lowerRequiresFeature}) into the canonical
* `visible` CEL predicate using {@link featureGatePredicate}.
* - plugin-auth drift guard (key-set equivalence with `getPublicConfig()`).
* - platform-objects completeness guard (`feature-gate-guard.test.ts`) —
* every path in `gatedInputs` must carry the matching predicate, and every
* `features.*` reference in a `visible` predicate must be booked here.
*
* This module is deliberately **import-free** (type-only zod import aside) so
* schema modules can depend on it file-directly without cycle risk, and it
* contains constants plus pure lowering helpers only (Prime Directive #2).
*/
/**
* Where a flag is consumed:
* - `crud` — admin/CRUD surface: action/param `visible` predicates rendered
* through objectui's `filterVisibleParams` chain. The surface this registry
* actively guards.
* - `login` — objectui login/auth UI reads the flag straight off
* `/auth/config` (no spec metadata in between).
* - `status` — operational status indicator, not a capability gate.
*/
export type PublicAuthFeatureSurface = 'crud' | 'login' | 'status';
/**
* Default semantics of the flag, which decide the lowered predicate:
* - `opt-in` — default `false`; gate with `features.X == true`.
* - `default-on` — default `true`; gate with `features.X != false` so a
* missing/undefined flag (e.g. config not yet fetched) keeps the input
* visible.
*/
export type PublicAuthFeatureSemantics = 'opt-in' | 'default-on';
export type PublicAuthFeatureEntry = {
surface: PublicAuthFeatureSurface;
semantics: PublicAuthFeatureSemantics;
/**
* Spec inputs gated on this flag. Path grammar:
* `<object>.actions.<action>` or `<object>.actions.<action>.params.<name|field>`.
* The platform-objects completeness guard resolves each path and asserts the
* target's `visible` predicate matches {@link featureGatePredicate}.
* Mutually exclusive with `exempt`.
*/
gatedInputs?: readonly string[];
/** Required when `gatedInputs` is absent — why no spec input needs gating. */
exempt?: { reason: string };
/** Audit notes: login-surface consumption sites, known gaps, follow-ups. */
notes?: string;
};
/**
* The registry. Keys mirror the boolean flags assembled in plugin-auth's
* `getPublicConfig()` (auth-manager.ts, `features` literal) — see the drift
* guard. Login-surface consumption sites below were audited against objectui
* on 2026-07-15 (#2874 P2②).
*/
export const PUBLIC_AUTH_FEATURES = {
twoFactor: {
surface: 'crud',
semantics: 'opt-in',
gatedInputs: [
'sys_user.actions.enable_two_factor',
'sys_user.actions.disable_two_factor',
'sys_user.actions.generate_backup_codes',
'sys_two_factor.actions.enable_two_factor',
'sys_two_factor.actions.disable_two_factor',
'sys_two_factor.actions.regenerate_backup_codes',
],
notes:
'Login-surface 2FA challenge is server-driven remediation (ADR-0069), ' +
'so the flag is intentionally unread by objectui LoginForm.',
},
passkeys: {
surface: 'login',
semantics: 'opt-in',
exempt: {
reason:
'No spec input to gate. Typed in objectui (auth/src/types.ts) but no ' +
'passkey UI exists yet — advertised-but-unconsumed gap tracked in ' +
'objectui#2514 (#2874 P2②).',
},
},
magicLink: {
surface: 'login',
semantics: 'opt-in',
exempt: {
reason:
'No spec input to gate. Typed in objectui (auth/src/types.ts) but no ' +
'magic-link UI exists yet — advertised-but-unconsumed gap tracked in ' +
'objectui#2514 (#2874 P2②).',
},
},
organization: {
surface: 'crud',
semantics: 'default-on',
gatedInputs: [
'sys_user.actions.invite_user',
'sys_member.actions.add_member',
'sys_member.actions.update_member_role',
'sys_member.actions.remove_member',
'sys_member.actions.transfer_ownership',
'sys_invitation.actions.invite_user',
'sys_invitation.actions.cancel_invitation',
'sys_invitation.actions.resend_invitation',
'sys_team.actions.create_team',
'sys_team.actions.update_team',
'sys_team.actions.remove_team',
'sys_team_member.actions.add_team_member',
'sys_team_member.actions.remove_team_member',
],
notes: 'Org CAPABILITY gate, not multi-org (ADR-0081 D1).',
},
multiOrgEnabled: {
surface: 'crud',
semantics: 'default-on',
gatedInputs: [
'sys_organization.actions.create_organization',
'sys_organization.actions.update_organization',
'sys_organization.actions.delete_organization',
'sys_organization.actions.set_active_organization',
'sys_organization.actions.leave_organization',
'sys_organization.actions.change_slug',
],
notes:
'Reflects ACTUAL multi-tenancy capability (the tenancy posture enforces ' +
'an organization wall — `group` or `isolated`, ADR-0093 D4 / ADR-0105 D1), ' +
'not just the requested posture.',
},
degradedTenancy: {
surface: 'status',
semantics: 'opt-in',
exempt: {
reason:
'Operator status banner (ADR-0093 D5) — signals degraded tenant ' +
'isolation, not an input capability gate.',
},
},
oidcProvider: {
surface: 'crud',
semantics: 'default-on',
gatedInputs: [
'sys_oauth_application.actions.create_oauth_application',
'sys_oauth_application.actions.delete_oauth_application',
'sys_oauth_application.actions.disable_oauth_application',
'sys_oauth_application.actions.enable_oauth_application',
'sys_oauth_application.actions.rotate_client_secret',
],
notes:
'Default-ON: the embedded OIDC authorization server follows the ' +
'default-on MCP surface (resolveOidcProviderEnabled). Login surface ' +
'consumes the socialProviders[] array (per-provider enabled), not this ' +
'flag.',
},
sso: {
surface: 'login',
semantics: 'opt-in',
exempt: {
reason:
'Deliberately ungated on the CRUD surface: the served value is ' +
'refined to "usable" (≥1 provider configured) via isSsoUsable() at ' +
'/auth/config, so gating sys_sso_provider registration actions on it ' +
'would deadlock first-provider setup. Login consumption verified: ' +
'objectui LoginForm gates the "Sign in with SSO" button.',
},
},
ssoEnforced: {
surface: 'login',
semantics: 'opt-in',
exempt: {
reason:
'Login-surface only: objectui LoginForm hides the password form and ' +
'self-registration (break-glass link remains). No spec input to gate.',
},
},
deviceAuthorization: {
surface: 'login',
semantics: 'opt-in',
exempt: {
reason:
'No spec input (sys_device_code declares no actions). Known gap: ' +
'objectui DeviceAuthPage hits the device-auth endpoints without ' +
'checking this flag (absent from its client type) — tracked in ' +
'objectui#2513 (#2874 P2②).',
},
},
admin: {
surface: 'crud',
semantics: 'opt-in',
gatedInputs: [
'sys_user.actions.create_user',
'sys_user.actions.ban_user',
'sys_user.actions.unban_user',
'sys_user.actions.unlock_user',
'sys_user.actions.set_user_password',
'sys_user.actions.set_user_role',
'sys_user.actions.impersonate_user',
],
notes: 'SCIM forces the admin plugin (and this flag) on — ADR-0071.',
},
phoneNumber: {
surface: 'crud',
semantics: 'opt-in',
gatedInputs: ['sys_user.actions.create_user.params.phoneNumber'],
notes:
'The original #2871 fix. Also read by objectui LoginForm for the ' +
'phone+password sign-in mode.',
},
phoneNumberOtp: {
surface: 'login',
semantics: 'opt-in',
exempt: {
reason:
'Login-surface only: gates the "sign in with verification code" link ' +
'(LoginForm) and the phone branch of forgot-password. Only advertised ' +
'when SMS is actually deliverable (#2780).',
},
},
} as const satisfies Record<string, PublicAuthFeatureEntry>;
export type PublicAuthFeatureName = keyof typeof PUBLIC_AUTH_FEATURES;
/** Tuple of registry keys — feeds `z.enum(...)` for the `requiresFeature` sugar. */
export const PUBLIC_AUTH_FEATURE_NAMES = Object.keys(PUBLIC_AUTH_FEATURES) as [
PublicAuthFeatureName,
...PublicAuthFeatureName[],
];
/**
* Non-boolean keys `getPublicConfig()` may spread into `features` (legal-link
* URLs; the tenancy posture). Exempt from FLAG classification — a flag is a
* boolean capability gate, and these are values — but the drift guard still
* asserts no OTHER non-boolean key sneaks in.
*
* `tenancyPosture` (ADR-0105 D1) reports WHICH of `single` | `group` |
* `isolated` is in force. It gates nothing: `multiOrgEnabled` remains the
* boolean capability gate ("is an organization wall enforced at all?"), while
* this tells the console how to render org context — under `group` the org
* switcher picks the WRITE target and reads span every organization the member
* belongs to.
*/
export const PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS = ['termsUrl', 'privacyUrl', 'tenancyPosture'] as const;
/**
* The canonical CEL gate for a flag, per its default semantics:
* `opt-in` → `features.X == true`; `default-on` → `features.X != false`
* (so an absent flag — e.g. config not yet fetched — fails open).
*/
export function featureGatePredicate(name: PublicAuthFeatureName): string {
const op = PUBLIC_AUTH_FEATURES[name].semantics === 'opt-in' ? '== true' : '!= false';
return `features.${name} ${op}`;
}
/** Object shape the lowering transform operates on (post field-level parse). */
type WithRequiresFeature = {
requiresFeature?: PublicAuthFeatureName;
/** Already normalized by ExpressionInputSchema to the `{dialect, source}` envelope. */
visible?: { dialect?: unknown; source?: unknown } & Record<string, unknown>;
};
/**
* Lower the declarative `requiresFeature: '<flag>'` sugar into the canonical
* `visible` CEL predicate and strip the sugar key from the output — mirroring
* `normalizeVisibleWhen` (ADR-0089): persisted artifacts, lint, runtime, and
* objectui only ever see the canonical envelope.
*
* - No existing `visible` → `{ dialect: 'cel', source: <gate> }`, string-equal
* to the hand-written gates it replaces.
* - Existing CEL `visible` with a `source` → composed as
* `(<existing>) && <gate>` (existing predicate first, gate last — the
* hand-written convention).
* - Existing `visible` that is non-CEL or AST-only → loud parse error
* (ADR-0078 no-silently-inert); write the combined predicate by hand.
*
* Designed as a zod `.transform((v, ctx) => lowerRequiresFeature(v, ctx))`
* appended after the schema's refinements.
*/
export function lowerRequiresFeature<T extends WithRequiresFeature>(
input: T,
ctx: z.core.$RefinementCtx,
): Omit<T, 'requiresFeature'> {
const { requiresFeature, ...rest } = input;
if (requiresFeature === undefined) return rest as Omit<T, 'requiresFeature'>;
const gate = featureGatePredicate(requiresFeature);
const existing = rest.visible;
if (existing === undefined) {
return { ...rest, visible: { dialect: 'cel', source: gate } } as Omit<T, 'requiresFeature'>;
}
if (existing.dialect !== 'cel' || typeof existing.source !== 'string') {
ctx.addIssue({
code: 'custom',
path: ['requiresFeature'],
message:
'`requiresFeature` composes only with a CEL `visible` carrying a `source` string; ' +
'this expression is AST-only or non-CEL — write the combined predicate by hand.',
});
return rest as Omit<T, 'requiresFeature'>;
}
return {
...rest,
visible: { ...existing, source: `(${existing.source}) && ${gate}` },
} as Omit<T, 'requiresFeature'>;
}