Skip to content

Commit efa072b

Browse files
committed
refactor(service-settings): the display language travels beside the settings context, not in it
Claude-Session: https://claude.ai/code/session_01Utp6CYUAEJV7QpzuJm62Te Co-authored-by: Claude <noreply@anthropic.com>
1 parent 45bcf4a commit efa072b

7 files changed

Lines changed: 38 additions & 16 deletions

File tree

‎packages/services/service-settings/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export {
4141
type SettingsWritePredicate,
4242
type SettingsWritePredicateInput,
4343
type SettingsWriteRefusal,
44+
type SettingsDisplayOptions,
4445
SETTINGS_LIVE_STATUS_BANNER_ID,
4546
type SettingsAuditSink,
4647
type SettingsContext,

‎packages/services/service-settings/src/localization-currency-lock.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ describe('the copy — every locale the settings bundle ships', () => {
235235
await svc.set('localization', 'currency', 'USD', ctx);
236236
objects.push(INVOICE);
237237
rates.push({ organization_id: ORG, currency: 'EUR' }, { organization_id: ORG, currency: 'JPY' });
238-
const err = await refusalOf(svc.set('localization', 'currency', 'EUR', { ...ctx, locale }));
238+
const err = await refusalOf(svc.setMany('localization', { currency: 'EUR' }, ctx, { locale }));
239239
expect(err.fields[0].message.startsWith(opening)).toBe(true);
240240
});
241241
});

‎packages/services/service-settings/src/localization-currency-lock.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -233,11 +233,11 @@ function usageConstraint(usage: CurrencyUsage): Record<string, unknown> {
233233
* refused while the currency is used. Setting it while unset passes.
234234
*/
235235
export function localizationCurrencyWritePredicate(engine: CurrencyUsageEngine): SettingsWritePredicate {
236-
return async ({ current, organizationId, ctx }) => {
236+
return async ({ current, organizationId, locale }) => {
237237
if (isUnset(current)) return undefined;
238238
const usage = await readCurrencyUsage(engine, organizationId ?? null);
239239
if (!isUsed(usage)) return undefined;
240-
const copy = currencyLockCopy(ctx.locale);
240+
const copy = currencyLockCopy(locale);
241241
return {
242242
message: copy.refusal(String(current), describeCurrencyUsage(copy, usage)),
243243
constraint: usageConstraint(usage),
@@ -257,12 +257,12 @@ export function localizationCurrencyLiveStatus(
257257
engine: CurrencyUsageEngine,
258258
currentCurrency: (ctx: Parameters<SettingsLiveStatusProvider>[0]['ctx']) => Promise<unknown>,
259259
): SettingsLiveStatusProvider {
260-
return async ({ ctx, organizationId }): Promise<SettingsLiveStatus | undefined> => {
260+
return async ({ ctx, organizationId, locale }): Promise<SettingsLiveStatus | undefined> => {
261261
const organization = await organizationId();
262262
if (organization === undefined) return undefined;
263263
const usage = await readCurrencyUsage(engine, organization);
264264
if (!isUsed(usage)) return undefined;
265-
const copy = currencyLockCopy(ctx.locale);
265+
const copy = currencyLockCopy(locale);
266266
const described = describeCurrencyUsage(copy, usage);
267267
const current = await currentCurrency(ctx);
268268
if (isUnset(current)) {

‎packages/services/service-settings/src/settings-routes.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,8 @@ function refuseHeldSession(res: IHttpResponse, ctx: SettingsContext | undefined)
205205
* The caller's display language, read the way REST's metadata door reads it:
206206
* the top `Accept-Language` tag, else `?locale`. It only picks the language of
207207
* prose a write predicate or a live-status provider composes
208-
* (`SettingsContext.locale`); it authorizes nothing.
208+
* (`SettingsDisplayOptions`); it authorizes nothing, and it never enters the
209+
* caller's context.
209210
*/
210211
function requestLocaleOf(req: IHttpRequest): string | undefined {
211212
const header = req.headers?.['accept-language'] ?? req.headers?.['Accept-Language'];
@@ -295,7 +296,7 @@ export function registerSettingsRoutes(
295296
// `getNamespace`, which in-process consumers call to read values: the
296297
// mail plugin reads `getNamespace('mail')` while rebuilding the very
297298
// transport its statement waits on.
298-
const liveStatus = await service.liveStatusOf(ns, { ...ctx, locale: requestLocaleOf(req) });
299+
const liveStatus = await service.liveStatusOf(ns, ctx, { locale: requestLocaleOf(req) });
299300
const manifest = liveStatus
300301
? { ...payload.manifest, specifiers: [liveStatus, ...payload.manifest.specifiers] }
301302
: payload.manifest;
@@ -353,8 +354,9 @@ export function registerSettingsRoutes(
353354
const result = await service.setMany(
354355
ns,
355356
dropEchoedSecretMasks(body, secretKeys),
357+
ctx,
356358
// The language a write predicate's refusal is written in.
357-
{ ...ctx, locale: requestLocaleOf(req) },
359+
{ locale: requestLocaleOf(req) },
358360
);
359361
// The write response carries resolved values too — including cascade
360362
// entries the caller never submitted (an upper-scope secret it may not

‎packages/services/service-settings/src/settings-service.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
type SettingsLiveStatus,
2424
type SettingsLiveStatusProvider,
2525
type SettingsWritePredicate,
26+
type SettingsDisplayOptions,
2627
SETTINGS_LIVE_STATUS_BANNER_ID,
2728
type SettingsAuditSink,
2829
type SettingsContext,
@@ -1619,6 +1620,7 @@ export class SettingsService {
16191620
async liveStatusOf(
16201621
namespace: string,
16211622
ctx: SettingsContext = {},
1623+
display: SettingsDisplayOptions = {},
16221624
): Promise<SettingsManifest['specifiers'][number] | undefined> {
16231625
const provider = this.liveStatusProviders.get(namespace);
16241626
if (!provider) return undefined;
@@ -1628,6 +1630,7 @@ export class SettingsService {
16281630
namespace,
16291631
ctx,
16301632
organizationId: async () => organizationReachRowOwner(await this.organizationReachOf(ctx)),
1633+
locale: display.locale,
16311634
});
16321635
} catch (err: any) {
16331636
this.logger?.warn?.(
@@ -2277,11 +2280,15 @@ export class SettingsService {
22772280
* naming each such key (`invalid_value`, `constraint: { scope: 'user' }`):
22782281
* the row it would store names no owner, and the user rung answers only its
22792282
* owner, so no reader would ever see it while the write reported success.
2283+
*
2284+
* `display` picks the language of a write predicate's refusal, and nothing
2285+
* else (see {@link refuseByWritePredicates}).
22802286
*/
22812287
async setMany(
22822288
namespace: string,
22832289
patch: Record<string, unknown>,
22842290
ctx: SettingsContext = {},
2291+
display: SettingsDisplayOptions = {},
22852292
): Promise<Record<string, ResolvedSettingValue>> {
22862293
// The pre-bind window: refuse rather than resolve against a store nothing
22872294
// will ever read. See `assertEngineBound`.
@@ -2365,7 +2372,7 @@ export class SettingsService {
23652372
// may refuse a change the declarations above accept. After them, so a
23662373
// malformed value is told about its own fault first; before the crypto
23672374
// pass and every write, so a refused batch writes nothing.
2368-
await this.refuseByWritePredicates(reg, namespace, patch, ctx, reach);
2375+
await this.refuseByWritePredicates(reg, namespace, patch, ctx, reach, display);
23692376

23702377
// #8026 — a declared-encrypted key with nothing able to encrypt it fails
23712378
// the WHOLE batch, here, rather than part-way down the write loop below.
@@ -2579,6 +2586,7 @@ export class SettingsService {
25792586
patch: Record<string, unknown>,
25802587
ctx: SettingsContext,
25812588
reach: OrganizationReach | undefined,
2589+
display: SettingsDisplayOptions,
25822590
): Promise<void> {
25832591
const byKey = this.writePredicates.get(namespace);
25842592
if (!byKey) return;
@@ -2591,7 +2599,7 @@ export class SettingsService {
25912599
if (stableStringify(next) === stableStringify(current)) continue;
25922600
const scope = reg.scopes.get(key)!;
25932601
const organizationId = scope === 'global' || !reach ? undefined : organizationReachRowOwner(reach) ?? null;
2594-
const refusal = await predicate({ namespace, key, next, current, organizationId, ctx });
2602+
const refusal = await predicate({ namespace, key, next, current, organizationId, ctx, locale: display.locale });
25952603
if (!refusal) continue;
25962604
const spec = ((reg.manifest.specifiers ?? []) as Array<Record<string, unknown>>)
25972605
.find((s) => s.key === key);

‎packages/services/service-settings/src/settings-service.types.ts‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,19 @@ export interface SettingsContext {
8282
* resolver sets it to have its held sessions refused too.
8383
*/
8484
authGate?: AuthGate;
85+
}
86+
87+
/**
88+
* How a settings call's computed prose is written — never who calls or what
89+
* they may do, which is {@link SettingsContext}'s alone.
90+
*/
91+
export interface SettingsDisplayOptions {
8592
/**
8693
* The caller's display language (BCP-47), for prose a
8794
* {@link SettingsWritePredicate} refusal or a {@link SettingsLiveStatusProvider}
88-
* statement composes. The settings routes set it from the request
89-
* (`Accept-Language`, else `?locale`). Never an authorization input and never
90-
* read by the resolver; absent, such prose is written in English.
95+
* statement composes. The settings routes pass it from the request
96+
* (`Accept-Language`, else `?locale`). Absent, such prose is written in
97+
* English.
9198
*/
9299
locale?: string;
93100
}
@@ -348,11 +355,13 @@ export interface SettingsWritePredicateInput {
348355
*/
349356
organizationId: string | null | undefined;
350357
ctx: SettingsContext;
358+
/** The language the refusal is written in — {@link SettingsDisplayOptions.locale}. */
359+
locale?: string;
351360
}
352361

353362
/**
354363
* A predicate's refusal. `message` is the sentence the caller reads under the
355-
* field, written in `ctx.locale`; `constraint` carries its discrete values
364+
* field, written in the input's `locale`; `constraint` carries its discrete values
356365
* (`FieldError.constraint`, ADR-0114), so a client can compose its own text.
357366
*/
358367
export interface SettingsWriteRefusal {
@@ -412,7 +421,9 @@ export type SettingsLiveStatusProvider = (input: {
412421
* Resolved only when called.
413422
*/
414423
organizationId: () => Promise<string | null | undefined>;
415-
}) =>Promise<SettingsLiveStatus | undefined> | SettingsLiveStatus | undefined;
424+
/** The language the statement is written in — {@link SettingsDisplayOptions.locale}. */
425+
locale?: string;
426+
}) => Promise<SettingsLiveStatus | undefined> | SettingsLiveStatus | undefined;
416427

417428
/**
418429
* The `info_banner` id that carries a namespace's live status. Reserved: a

‎packages/services/service-settings/src/settings-write-predicate.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ describe('SettingsService write predicate', () => {
6969
const { svc, spy } = rig(({ current }) => (current ? { message: 'Locked.' } : undefined));
7070
await svc.set('localization', 'currency', 'USD', ctx);
7171
await expect(svc.set('localization', 'currency', null, ctx)).rejects.toBeInstanceOf(SettingsValidationError);
72-
expect(spy.mock.calls.at(-1)![0]).toMatchObject({ current: 'USD', next: null });
72+
expect(spy.mock.calls[spy.mock.calls.length - 1][0]).toMatchObject({ current: 'USD', next: null });
7373
expect((await svc.get('localization', 'currency', ctx)).value).toBe('USD');
7474
});
7575

0 commit comments

Comments
 (0)