Skip to content

Commit 8e31083

Browse files
os-trumpclaude
andauthored
feat(settings,auth): expose the audience posture in the auth settings namespace (#12301)
* feat(settings,auth): expose the audience posture in the auth settings namespace Three new authorable keys in an audience group on the auth namespace (audience_posture select over the closed vocabulary, a domain-list textarea, a permission-set name field), mapped by bindAuthSettings to one applyConfigPatch({ audience }) that replaces the whole audience object and validates the merged result. Explicit-only application; off-vocabulary postures refused loudly and never coerced (#5152 precedent); every #11739 invariant holds through the settings channel and refusals fail closed. Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa Co-authored-by: Claude <noreply@anthropic.com> * test(settings): cover the audience keys in the locale bundles and move the visibility-corpus pin The three gated locale bundles (zh-CN / ja-JP / es-ES) gain the audience group and its three keys (en updated for parity); the #7169 visibility corpus tripwire moves 94 -> 96 for the two new posture-gated predicates. Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5619aac commit 8e31083

10 files changed

Lines changed: 646 additions & 2 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/service-settings": minor
3+
"@objectstack/plugin-auth": minor
4+
---
5+
6+
feat(settings,auth): expose the audience posture in the `auth` settings namespace (#11768)
7+
8+
The audience posture shipped by #11739 (`invite_only | email_domain | open`,
9+
default `invite_only`) was switchable only from stack config at boot; a
10+
self-host admin had no console channel. The `auth` settings namespace now
11+
carries an `audience` group — three new authorable keys, which is what a host
12+
sees and why this is `minor`:
13+
14+
- `audience_posture` — a select over the closed vocabulary (the option table
15+
is enforced on `setMany` and on the `OS_AUTH_AUDIENCE_POSTURE` env-override
16+
door);
17+
- `audience_allowed_email_domains` — newline- or comma-separated bare domains
18+
(exact, case-insensitive matching; subdomains need their own entries);
19+
- `audience_self_registration_permission_set` — the `sys_permission_set` name
20+
each self-registrant receives.
21+
22+
`bindAuthSettings` maps the three keys — one atomic declaration — to one
23+
`AuthManager.applyConfigPatch({ audience })`, which replaces the whole
24+
audience object and validates the MERGED result. Every #11739 invariant holds
25+
through the new channel: a self-registration posture with verification
26+
explicitly off, an empty domain list under `email_domain`, and a missing or
27+
`admin_full_access` permission set are all refused loudly (the standing
28+
config keeps ruling — fail closed), and off-vocabulary postures are refused,
29+
never coerced, per the `membership_policy` precedent (#5152). Only EXPLICIT
30+
settings values apply: the manifest defaults never mask a deployment's
31+
boot-config declaration. Switching back to `invite_only` always applies —
32+
leftover text in the posture-hidden sibling fields cannot make closing the
33+
wall refusable.
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `auth.audience_*` — the console switch surface for the audience posture
5+
* (#11768, consuming the #11739 / PR #11767 contract).
6+
*
7+
* The three settings keys (`audience_posture`, `audience_allowed_email_domains`,
8+
* `audience_self_registration_permission_set`) are ONE atomic declaration that
9+
* `bindAuthSettings` maps to ONE `applyConfigPatch({ audience })` — the patch
10+
* replaces the whole audience object and validates the MERGED result
11+
* (`assertAudienceConfig`), so the settings channel can never reach a posture
12+
* the boot-config channel could not.
13+
*
14+
* The load-bearing pins here are the REFUSALS (#5152: explicit-only, loud
15+
* refusal, never coercion). A suite that only sets valid postures cannot tell
16+
* an enforcing binding from a pass-through, so each ruled invariant is driven
17+
* through the settings channel to its refusal:
18+
*
19+
* - empty domain list under `email_domain`;
20+
* - missing / forbidden (`admin_full_access`) self-registration permission set;
21+
* - posture ≠ `invite_only` with verification explicitly off.
22+
*
23+
* Envelope note: refusals at THIS seam are log-line refusals (the binding runs
24+
* inside `applySettings`, not on an HTTP surface), so the assertions pin the
25+
* logger level + message content. The settings channel's envelope-carrying
26+
* refusal (`SETTINGS_VALIDATION` + `invalid_option` on `setMany`) is pinned in
27+
* `service-settings`' auth.manifest.test.ts, and the admission-time envelope
28+
* (403 + `AUTH_CONFIG_ERROR`/`SELF_REGISTRATION_CLOSED`) is pinned in
29+
* audience-posture.test.ts — the settings channel converges on the same
30+
* `getAudience()` accessor those tests exercise.
31+
*/
32+
33+
import { describe, it, expect, vi, beforeEach } from 'vitest';
34+
import type { PluginContext } from '@objectstack/core';
35+
import { AUDIENCE_POSTURES } from '@objectstack/spec/system';
36+
import { AuthPlugin } from './auth-plugin.js';
37+
import { AuthManager } from './auth-manager.js';
38+
39+
const SECRET = 'test-secret-at-least-32-chars-long';
40+
41+
type SettingEntry = { value: unknown; source: string };
42+
43+
/** Minimal engine: enough for boot-time hooks (backfill sees zero users). */
44+
function makeEngine() {
45+
return {
46+
insert: vi.fn(async (_object: string, row: any) => row),
47+
find: vi.fn(async () => []),
48+
findOne: vi.fn(async () => null),
49+
};
50+
}
51+
52+
describe('auth.audience_* — the settings switch surface (#11768)', () => {
53+
let mockContext: PluginContext;
54+
let hookHandlers: Map<string, Array<() => Promise<void>>>;
55+
56+
const settingsStore: { values: Record<string, SettingEntry> } = { values: {} };
57+
let subscribers: Array<() => void>;
58+
59+
const makeSettings = () => ({
60+
getNamespace: vi.fn(async (namespace: string) =>
61+
namespace === 'auth' ? { values: settingsStore.values } : { values: {} },
62+
),
63+
subscribe: vi.fn((namespace: string, cb: () => void) => {
64+
if (namespace === 'auth') subscribers.push(cb);
65+
}),
66+
});
67+
68+
beforeEach(() => {
69+
settingsStore.values = {};
70+
subscribers = [];
71+
hookHandlers = new Map();
72+
mockContext = {
73+
registerService: vi.fn(),
74+
getService: vi.fn((name: string) => {
75+
if (name === 'manifest') return { register: vi.fn() };
76+
if (name === 'settings') return makeSettings();
77+
return undefined;
78+
}),
79+
getServices: vi.fn(() => new Map()),
80+
hook: vi.fn((name: string, handler: () => Promise<void>) => {
81+
if (!hookHandlers.has(name)) hookHandlers.set(name, []);
82+
hookHandlers.get(name)!.push(handler);
83+
}),
84+
trigger: vi.fn(),
85+
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
86+
getKernel: vi.fn(),
87+
} as unknown as PluginContext;
88+
});
89+
90+
const fire = async (name: string) => {
91+
for (const h of hookHandlers.get(name) ?? []) await h();
92+
};
93+
94+
/** Boot exactly as a host does: init → start → kernel:ready (settings bind there). */
95+
const boot = async (opts: {
96+
settings?: Record<string, SettingEntry>;
97+
pluginOptions?: Record<string, unknown>;
98+
} = {}) => {
99+
settingsStore.values = opts.settings ?? {};
100+
const engine = makeEngine();
101+
(mockContext.getService as any).mockImplementation((name: string) => {
102+
if (name === 'manifest') return { register: vi.fn() };
103+
if (name === 'settings') return makeSettings();
104+
if (name === 'data' || name === 'objectql') return engine;
105+
return undefined;
106+
});
107+
108+
const plugin = new AuthPlugin({
109+
secret: SECRET,
110+
baseUrl: 'http://localhost:3000',
111+
...(opts.pluginOptions ?? {}),
112+
});
113+
await plugin.init(mockContext);
114+
const manager = (mockContext.registerService as any).mock.calls.find(
115+
([name]: [string]) => name === 'auth',
116+
)?.[1] as AuthManager;
117+
await plugin.start(mockContext);
118+
await fire('kernel:ready');
119+
return { plugin, manager, engine };
120+
};
121+
122+
const errorLines = () =>
123+
(mockContext.logger.error as any).mock.calls.map((c: any[]) => String(c[0]));
124+
125+
// ── 1. The declaration reaches the runtime, whole, through ONE patch ─────
126+
127+
it('an explicit email_domain declaration patches the live audience in one stroke', async () => {
128+
const { manager } = await boot({
129+
settings: {
130+
audience_posture: { value: 'email_domain', source: 'global' },
131+
audience_allowed_email_domains: { value: 'acme.com\n Beta.Org , partner.example', source: 'global' },
132+
audience_self_registration_permission_set: { value: ' portal_user ', source: 'global' },
133+
},
134+
});
135+
const audience = manager.getAudience();
136+
expect(audience.posture).toBe('email_domain');
137+
// Newline- AND comma-separated, trimmed; case preserved (matching is
138+
// case-insensitive at admission, declaration keeps the typed form).
139+
expect(audience.allowedEmailDomains).toEqual(['acme.com', 'Beta.Org', 'partner.example']);
140+
expect(audience.selfRegistrationPermissionSet).toBe('portal_user');
141+
});
142+
143+
it('re-applies on a settings change without a restart', async () => {
144+
const { manager } = await boot();
145+
expect(manager.getAudience().posture).toBe('invite_only');
146+
147+
settingsStore.values = {
148+
audience_posture: { value: 'email_domain', source: 'global' },
149+
audience_allowed_email_domains: { value: 'acme.com', source: 'global' },
150+
audience_self_registration_permission_set: { value: 'member_default', source: 'global' },
151+
};
152+
expect(subscribers.length).toBeGreaterThan(0);
153+
for (const cb of subscribers) cb();
154+
await vi.waitFor(() => expect(manager.getAudience().posture).toBe('email_domain'));
155+
});
156+
157+
// ── 2. Explicit-only (#5152): a UI default must not mask deployment config ─
158+
159+
it('a manifest default does not mask a deployment that declared its audience in code', async () => {
160+
const { manager } = await boot({
161+
pluginOptions: {
162+
audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' },
163+
},
164+
settings: { audience_posture: { value: 'invite_only', source: 'default' } },
165+
});
166+
expect(manager.getAudience().posture).toBe('open');
167+
});
168+
169+
// ── 3. Off-vocabulary: refused loudly, never coerced ─────────────────────
170+
171+
it('an off-vocabulary posture is refused loudly, never coerced (#5152)', async () => {
172+
// `invite-only` is the MEMBERSHIP policy spelling — the most plausible
173+
// operator typo for this select's `invite_only`. Coercing it (to either
174+
// end of the vocabulary) would silently pick a posture the operator did
175+
// not declare; refusing keeps the standing config ruling and says so.
176+
const { manager } = await boot({
177+
pluginOptions: {
178+
audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' },
179+
},
180+
settings: { audience_posture: { value: 'invite-only', source: 'env' } },
181+
});
182+
expect(manager.getAudience().posture).toBe('open'); // standing, NOT a coerced guess
183+
const logged = errorLines();
184+
expect(logged.some((m: string) => m.includes("'invite-only'"))).toBe(true);
185+
expect(logged.some((m: string) => m.includes(AUDIENCE_POSTURES.join(', ')))).toBe(true);
186+
});
187+
188+
// ── 4. The ruled invariants hold THROUGH the settings channel ────────────
189+
// `applyConfigPatch` validates the merged result and throws; the binding
190+
// catches and reports, and the standing config keeps ruling (fail closed).
191+
192+
it('an EMPTY domain list under email_domain is refused through the settings channel', async () => {
193+
const { manager } = await boot({
194+
settings: {
195+
audience_posture: { value: 'email_domain', source: 'global' },
196+
// Explicit but blank (whitespace + separators only) — parses to [].
197+
audience_allowed_email_domains: { value: ' \n , ', source: 'global' },
198+
audience_self_registration_permission_set: { value: 'portal_user', source: 'global' },
199+
},
200+
});
201+
expect(manager.getAudience().posture).toBe('invite_only'); // standing default keeps ruling
202+
const logged = errorLines();
203+
expect(logged.some((m: string) => m.includes('audience settings REFUSED'))).toBe(true);
204+
expect(logged.some((m: string) => m.includes('allowedEmailDomains'))).toBe(true);
205+
});
206+
207+
it('a MISSING self-registration permission set is refused through the settings channel', async () => {
208+
const { manager } = await boot({
209+
settings: { audience_posture: { value: 'open', source: 'global' } },
210+
});
211+
expect(manager.getAudience().posture).toBe('invite_only');
212+
const logged = errorLines();
213+
expect(logged.some((m: string) => m.includes('audience settings REFUSED'))).toBe(true);
214+
expect(logged.some((m: string) => m.includes('selfRegistrationPermissionSet'))).toBe(true);
215+
});
216+
217+
it('admin_full_access as the self-registration permission set is refused', async () => {
218+
const { manager } = await boot({
219+
settings: {
220+
audience_posture: { value: 'open', source: 'global' },
221+
audience_self_registration_permission_set: { value: 'admin_full_access', source: 'global' },
222+
},
223+
});
224+
expect(manager.getAudience().posture).toBe('invite_only');
225+
expect(errorLines().some((m: string) => m.includes('admin_full_access'))).toBe(true);
226+
});
227+
228+
it('a self-registration posture with verification explicitly OFF is refused', async () => {
229+
// The audience patch is applied AFTER the main patch, so the merged-result
230+
// validation judges it against the `require_email_verification: false`
231+
// this same pass just applied — the #11739 "verification forced when
232+
// posture permits self-registration" invariant, held through the new door.
233+
const { manager } = await boot({
234+
settings: {
235+
require_email_verification: { value: false, source: 'global' },
236+
audience_posture: { value: 'open', source: 'global' },
237+
audience_self_registration_permission_set: { value: 'member_default', source: 'global' },
238+
},
239+
});
240+
// The main patch itself applied…
241+
expect((manager as any).config.emailAndPassword?.requireEmailVerification).toBe(false);
242+
// …and the audience that contradicts it was refused: standing rules.
243+
expect(manager.getAudience().posture).toBe('invite_only');
244+
expect(errorLines().some((m: string) => m.includes('requireEmailVerification'))).toBe(true);
245+
});
246+
247+
// ── 5. Composition: posture anchors the declaration ──────────────────────
248+
249+
it('switching BACK to invite_only never fails on leftover sibling fields', async () => {
250+
// The console keeps stored values for fields the posture select now hides.
251+
// Sending them would make CLOSING the wall refusable (inert-declaration
252+
// refusal) while the previous, more open posture keeps ruling — the one
253+
// direction that must not fail. The binding composes the declaration from
254+
// the keys the selected posture READS: `{ posture: 'invite_only' }` alone.
255+
const { manager } = await boot({
256+
pluginOptions: {
257+
audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' },
258+
},
259+
settings: {
260+
audience_posture: { value: 'invite_only', source: 'global' },
261+
audience_allowed_email_domains: { value: 'acme.com', source: 'global' },
262+
audience_self_registration_permission_set: { value: 'member_default', source: 'global' },
263+
},
264+
});
265+
expect(manager.getAudience().posture).toBe('invite_only');
266+
expect(errorLines()).toEqual([]);
267+
});
268+
269+
it('a domain list without an explicit posture is refused, not guessed', async () => {
270+
const { manager } = await boot({
271+
settings: { audience_allowed_email_domains: { value: 'acme.com', source: 'global' } },
272+
});
273+
expect(manager.getAudience().posture).toBe('invite_only');
274+
expect((manager as any).config.audience).toBeUndefined(); // no patch went out
275+
expect(errorLines().some((m: string) => m.includes('without a posture'))).toBe(true);
276+
});
277+
278+
// ── 6. Blast radius: a refused audience never blocks sibling settings ────
279+
280+
it('a refused audience declaration does not block sibling auth settings', async () => {
281+
const { manager } = await boot({
282+
settings: {
283+
session_expiry_days: { value: 3, source: 'global' },
284+
audience_posture: { value: 'open', source: 'global' }, // no permission set ⇒ refused
285+
},
286+
});
287+
expect((manager as any).config.session?.expiresIn).toBe(3 * 86_400);
288+
expect(manager.getAudience().posture).toBe('invite_only');
289+
expect(errorLines().some((m: string) => m.includes('audience settings REFUSED'))).toBe(true);
290+
});
291+
292+
// ── 7. Dangling names are an ADMISSION-time refusal, same accessor ───────
293+
294+
it('a well-formed but dangling permission-set name flows to the ONE accessor the admission gate reads', async () => {
295+
// The patch validator cannot resolve names (no data access); the dangling
296+
// declaration is refused at ADMISSION time with 403 AUTH_CONFIG_ERROR —
297+
// pinned in audience-posture.test.ts ("a DANGLING declared permission set
298+
// refuses admission…"). That gate reads `getAudience()`, so this pin —
299+
// the settings channel landing on the same accessor — is what connects
300+
// the two: no captured copy, no second read site.
301+
const { manager } = await boot({
302+
settings: {
303+
audience_posture: { value: 'open', source: 'global' },
304+
audience_self_registration_permission_set: { value: 'ghost', source: 'global' },
305+
},
306+
});
307+
const audience = manager.getAudience();
308+
expect(audience.posture).toBe('open');
309+
expect(audience.selfRegistrationPermissionSet).toBe('ghost');
310+
});
311+
});

0 commit comments

Comments
 (0)