Skip to content

Commit 82a06af

Browse files
os-zhuangclaude
andauthored
fix(service-settings): 保存期强制 select 声明的 options (#5131) (#5151)
`SettingsService.validatePatch` 只校验 `required` 与 `pattern` 两项, manifest 声明的 `options` 表从头到尾不参与校验。走控制台碰不到——下拉框 只会发出合法值;但 `PUT /api/settings/:ns` 是公开的可授权面,脚本、迁移 工具、AI 写的初始化代码可以直接写入枚举外的值,而且一路静默:存下来了, 读回来了,消费端各自随机应对。这不是 mail 专有的,所有 `type: 'select'` 的键都一样。 这正是 #5094 缺失的 API 侧闸门:那一单把 `sendgrid` / `ses` 从 mail 的 选项表里退役(本服务器无法通过它们投递),而没有写入期强制,刚退役的值 当天就能被重新写回去。 现在 `select` / `radio` / `multiselect` 的越界值以 `invalid_option` 拒绝, `constraint` 带上允许值集合(ADR-0114:constraint kind 在失败点打戳, 不让路由层从文案反推)。强制的类型集合取自 spec 自身——`SpecifierSchema` 的 superRefine 恰好要求这三类声明非空 `options`,所以「声明」与「强制」 指的是同一份清单,不存在第三份会漂移的列表。 两条刻意的边界: - **按 touch 语义校验**,与既有 required/pattern 一致。存量越界值只让 写该键的那次 patch 失败,只改 `from_name` 不会因为库里躺着一个老的 `provider` 而被拒。相反做法会把带历史脏值的工作区锁死在设置页里改不动 任何东西,比现状更糟。全 null 的重置永不阻塞。 - **没有声明 options 的 specifier 放行**:它说不出什么是合法的,保持宽容 而不是拒绝所有写入。 值按字符串形态比较,声明为 `value: 30` 的选项经 JSON 或表单往返后仍然匹配。 不设逃生舱:确需自定义值的 manifest 应在 spec 侧显式声明,而不是靠消费端宽容。 Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5ea8e1e commit 82a06af

5 files changed

Lines changed: 369 additions & 4 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/service-settings": patch
3+
---
4+
5+
fix(service-settings): a settings `select` now rejects values outside its declared `options` (#5131)
6+
7+
`SettingsService.validatePatch` enforced two of the constraints a settings
8+
manifest declares — `required` and `pattern` — and skipped the third. A
9+
specifier's `options` table never took part in save-time validation, so any
10+
string at all could be written into a dropdown field:
11+
12+
```ts
13+
await svc.setMany('mail', { provider: 'sendgrid', from_email: 'a@b.com' }); // stored
14+
```
15+
16+
Going through the console this was unreachable: the dropdown only ever emits a
17+
value from the table. But `PUT /api/settings/:ns` is an authorizable public
18+
surface, and scripts, migration tools and AI-authored bootstrap code write it
19+
directly — where the bad value was accepted, persisted and read back **in
20+
silence**, leaving every consumer to improvise its own answer for an
21+
enumeration member that does not exist. It was not `mail`-specific:
22+
`storage.adapter`, `sms.provider`, `ai.provider`, `localization.date_format` and
23+
every other `select` behaved the same way.
24+
25+
This is the API-side gate that #5094 was missing. That change retired
26+
`sendgrid` / `ses` from the `mail` provider table because this server cannot
27+
deliver through them — with no write-side enforcement, the values it had just
28+
retired could be written straight back in the same afternoon.
29+
30+
**Now:** a `select` / `radio` / `multiselect` value that is not a member of the
31+
declared table is rejected with a `FieldError` whose `code` is `invalid_option`
32+
and whose `constraint` carries the allowed set (`{ allowed: 'smtp, resend,
33+
postmark, log' }`), so a client composes its own message instead of parsing
34+
ours. The enforced set is the spec's own: `SpecifierSchema` already *requires* a
35+
non-empty `options` on exactly those three types, so declared and enforced name
36+
one list rather than two that can drift.
37+
38+
Two deliberate limits keep this from breaking workspaces that already carry
39+
drift:
40+
41+
- **The check is gated on TOUCH**, like `required` and `pattern` before it. A
42+
value that pre-dates the current option table only fails the patch that
43+
writes that key — editing `from_name` is not rejected because a stale
44+
`provider` sits in the store. The opposite rule would lock every workspace
45+
with historical drift out of its own settings page entirely, which is worse
46+
than the gap being closed. Resets (all-null patches) are never blocked.
47+
- **A specifier that declares no option table is left alone.** It cannot say
48+
what is legal, so it stays lenient rather than rejecting every write.
49+
50+
Values are compared in string form, so an option declared `value: 30` still
51+
matches after a round trip through JSON or a form post. There is no opt-out: a
52+
manifest that needs to accept custom values would declare that explicitly in
53+
the spec, not rely on a tolerant consumer.

packages/services/service-settings/src/envelope.conformance.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,33 @@ describe('settings envelope (#4224) — SETTINGS_VALIDATION speaks the field-lev
430430
expect(fields.map((f: any) => f.code).sort()).toEqual(['invalid_format', 'required']);
431431
});
432432

433+
it('an out-of-table select reaches the client as a parseable invalid_option (#5131)', async () => {
434+
const { http, service } = mount();
435+
service.registerManifest({
436+
namespace: 'enumerated',
437+
label: 'Enumerated',
438+
writePermission: 'setup.write',
439+
readPermission: 'setup.access',
440+
specifiers: [
441+
{ key: 'provider', type: 'select', label: 'Provider',
442+
options: [{ value: 'smtp', label: 'SMTP' }, { value: 'log', label: 'Log' }] },
443+
],
444+
} as any);
445+
const { status, body } = await drive(http, 'PUT /api/settings/:namespace', {
446+
params: { namespace: 'enumerated' },
447+
body: { provider: 'sendgrid' },
448+
});
449+
expect(status).toBe(400);
450+
expect(body.error.code).toBe('SETTINGS_VALIDATION');
451+
452+
const [field] = body.error.details.fields;
453+
expect(FieldErrorSchema.safeParse(field).success).toBe(true);
454+
// The constraint kind is stamped where the check failed, so the route
455+
// never has to infer it back out of the prose (ADR-0114).
456+
expect(field.code).toBe('invalid_option');
457+
expect(field.constraint).toEqual({ allowed: 'smtp, log' });
458+
});
459+
433460
it('the pre-#4224 map is gone from both of its old spellings', async () => {
434461
const http = lockedPattern();
435462
const { body } = await drive(http, 'PUT /api/settings/:namespace', {

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

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,188 @@ describe('SettingsService — save-time validation (required/visible/pattern)',
413413
});
414414
});
415415

416+
/**
417+
* #5131 — a manifest's `options` table is enforced at SAVE time.
418+
*
419+
* Until this suite existed the enumeration was a front-end convention: the
420+
* console dropdown only ever emitted legal values, so an admin going through
421+
* the UI could not produce a bad one — but `PUT /api/settings/:ns` is an
422+
* authorizable public surface, and a script, a migration or AI-authored
423+
* bootstrap code could write any string at all into a `select` and have it
424+
* stored, read back, and improvised over by each consumer in turn.
425+
*
426+
* The load-bearing case is `mail.provider`: #5094/#5133 retired `sendgrid`
427+
* and `ses` from the option table because this server cannot deliver through
428+
* them, and that manifest-side tightening had no matching gate on the API
429+
* side — the very values just retired could be written straight back in.
430+
*/
431+
describe('SettingsService — save-time validation (declared options are enforced)', () => {
432+
const mailService = () => {
433+
const svc = new SettingsService({ env: {} });
434+
svc.registerManifest(mailSettingsManifest);
435+
return svc;
436+
};
437+
438+
it('refuses a provider outside the declared table, naming the allowed set', async () => {
439+
const svc = mailService();
440+
// `sendgrid` left the table in #5094; before this gate it could be written
441+
// back the same afternoon it was retired.
442+
await expect(
443+
svc.setMany('mail', { provider: 'sendgrid', from_email: 'a@b.com' }),
444+
).rejects.toMatchObject({
445+
code: 'SETTINGS_VALIDATION',
446+
fields: [
447+
{
448+
field: 'provider',
449+
code: 'invalid_option',
450+
label: 'Provider',
451+
// The allowed set travels as a discrete constraint (ADR-0114), so a
452+
// client branches on the machine value instead of parsing our prose.
453+
constraint: { allowed: 'smtp, resend, postmark, log' },
454+
value: 'sendgrid',
455+
},
456+
],
457+
});
458+
// Atomic: the rejected batch persisted nothing, not even the legal key.
459+
expect((await svc.get('mail', 'provider')).source).toBe('default');
460+
expect((await svc.get('mail', 'from_email')).value).toBeNull();
461+
});
462+
463+
it('accepts every value the table does declare', async () => {
464+
for (const [provider, extra] of [
465+
['smtp', { smtp_host: 'smtp.example.com' }],
466+
['resend', { api_key: 're-key' }],
467+
['postmark', { api_key: 'pm-key' }],
468+
['log', {}],
469+
] as const) {
470+
const svc = mailService();
471+
await expect(
472+
svc.setMany('mail', { provider, ...extra, from_email: 'a@b.com' }),
473+
).resolves.toBeDefined();
474+
expect((await svc.get('mail', 'provider')).value).toBe(provider);
475+
}
476+
});
477+
478+
it('checks the option table only when the patch TOUCHES the key', async () => {
479+
// A workspace that saved `sendgrid` while the option existed still carries
480+
// it. Simulated exactly as it happened: write under the OLD table, then
481+
// re-register the narrowed manifest (#5094) over the same namespace.
482+
const svc = new SettingsService({ env: {} });
483+
svc.registerManifest({
484+
...mailSettingsManifest,
485+
specifiers: mailSettingsManifest.specifiers.map((s: any) =>
486+
s.key === 'provider'
487+
? { ...s, options: [...s.options, { value: 'sendgrid', label: 'SendGrid' }] }
488+
: s,
489+
),
490+
} as any);
491+
await svc.setMany('mail', { provider: 'sendgrid', api_key: 'sg-key', from_email: 'a@b.com' });
492+
svc.registerManifest(mailSettingsManifest);
493+
494+
// The stale value is still there …
495+
expect((await svc.get('mail', 'provider')).value).toBe('sendgrid');
496+
// … and it does NOT lock the workspace out of editing anything else. A
497+
// patch that never mentions `provider` is not rejected on its account —
498+
// the opposite rule would make the settings page unusable for every
499+
// workspace carrying historical drift, which is worse than the gap.
500+
await expect(svc.setMany('mail', { from_name: 'Acme Ops' })).resolves.toBeDefined();
501+
expect((await svc.get('mail', 'from_name')).value).toBe('Acme Ops');
502+
// Only re-writing the key itself is refused.
503+
await expect(svc.setMany('mail', { provider: 'sendgrid' })).rejects.toMatchObject({
504+
code: 'SETTINGS_VALIDATION',
505+
fields: [{ field: 'provider', code: 'invalid_option' }],
506+
});
507+
// And a reset still clears it — an all-null patch is never blocked.
508+
await expect(svc.resetNamespace('mail')).resolves.toBeGreaterThan(0);
509+
});
510+
511+
it('leaves the value alone when the specifier declares no option table', async () => {
512+
// `registerManifest` takes manifests as given (no Zod pass), so a
513+
// hand-built select without `options` reaches the validator. It cannot say
514+
// what is legal, so it stays lenient rather than rejecting every write.
515+
const svc = new SettingsService({ env: {} });
516+
svc.registerManifest({
517+
namespace: 'freeform',
518+
label: 'Freeform',
519+
specifiers: [{ type: 'select', key: 'mode', label: 'Mode' }],
520+
} as any);
521+
await expect(svc.setMany('freeform', { mode: 'whatever' })).resolves.toBeDefined();
522+
});
523+
524+
it('enforces radio and multiselect from the same table, element-wise', async () => {
525+
// All three types are covered because the SPEC requires an `options` table
526+
// on all three; `radio`/`multiselect` have no producer manifest today and
527+
// would otherwise be a hole the first one to author them falls into.
528+
const svc = new SettingsService({ env: {} });
529+
svc.registerManifest({
530+
namespace: 'shapes',
531+
label: 'Shapes',
532+
specifiers: [
533+
{ type: 'radio', key: 'tier', label: 'Tier',
534+
options: [{ value: 'free', label: 'Free' }, { value: 'pro', label: 'Pro' }] },
535+
{ type: 'multiselect', key: 'channels', label: 'Channels',
536+
options: [{ value: 'email', label: 'Email' }, { value: 'sms', label: 'SMS' }] },
537+
],
538+
} as any);
539+
540+
await expect(svc.setMany('shapes', { tier: 'enterprise' })).rejects.toMatchObject({
541+
fields: [{ field: 'tier', code: 'invalid_option', constraint: { allowed: 'free, pro' } }],
542+
});
543+
await expect(svc.setMany('shapes', { tier: 'pro' })).resolves.toBeDefined();
544+
545+
// Every element is checked, and the rejected one is the one reported.
546+
await expect(
547+
svc.setMany('shapes', { channels: ['email', 'carrier-pigeon'] }),
548+
).rejects.toMatchObject({
549+
fields: [{ field: 'channels', code: 'invalid_option', value: 'carrier-pigeon' }],
550+
});
551+
await expect(svc.setMany('shapes', { channels: ['email', 'sms'] })).resolves.toBeDefined();
552+
await expect(svc.setMany('shapes', { channels: [] })).resolves.toBeDefined();
553+
});
554+
555+
it('matches option values by string form, so a number option survives JSON', async () => {
556+
// A stored value has been through JSON and, over REST, a form post: an
557+
// option declared `value: 30` legitimately reads back as '30'. Rejecting
558+
// that would enforce the transport rather than the enumeration.
559+
const svc = new SettingsService({ env: {} });
560+
svc.registerManifest({
561+
namespace: 'retention',
562+
label: 'Retention',
563+
specifiers: [
564+
{ type: 'select', key: 'days', label: 'Days',
565+
options: [{ value: 7, label: '7' }, { value: 30, label: '30' }] },
566+
{ type: 'select', key: 'archive', label: 'Archive',
567+
options: [{ value: true, label: 'On' }, { value: false, label: 'Off' }] },
568+
],
569+
} as any);
570+
await expect(svc.setMany('retention', { days: 30 })).resolves.toBeDefined();
571+
await expect(svc.setMany('retention', { days: '30' })).resolves.toBeDefined();
572+
await expect(svc.setMany('retention', { archive: false })).resolves.toBeDefined();
573+
await expect(svc.setMany('retention', { days: 45 })).rejects.toMatchObject({
574+
fields: [{ field: 'days', code: 'invalid_option', constraint: { allowed: '7, 30' } }],
575+
});
576+
});
577+
578+
it('never echoes the rejected value for an encrypted specifier', async () => {
579+
// `encrypted` is authorable on any specifier, and this message lands in
580+
// logs — so the offending value is named only where it is safe to name.
581+
const svc = new SettingsService({ env: {} });
582+
svc.registerManifest({
583+
namespace: 'vault',
584+
label: 'Vault',
585+
specifiers: [
586+
{ type: 'select', key: 'key_ref', label: 'Key', encrypted: true,
587+
options: [{ value: 'primary', label: 'Primary' }] },
588+
],
589+
} as any);
590+
const err = await svc.setMany('vault', { key_ref: 's3cr3t-handle' }).catch((e) => e);
591+
expect(err.code).toBe('SETTINGS_VALIDATION');
592+
expect(err.fields[0]).toMatchObject({ field: 'key_ref', code: 'invalid_option' });
593+
expect(err.fields[0].value).toBeUndefined();
594+
expect(err.message).not.toContain('s3cr3t-handle');
595+
});
596+
});
597+
416598
describe('SettingsService — user-scoped values', () => {
417599
it('isolates writes by ctx.userId', async () => {
418600
const svc = new SettingsService({ env: {} });

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

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,43 @@ const LAYOUT_ONLY_TYPES = new Set([
4646
'action_button',
4747
]);
4848

49+
/**
50+
* Specifier types whose stored value must be a member of the declared
51+
* `options` table.
52+
*
53+
* THE list is the spec's, not a judgement call made here: `SpecifierSchema`'s
54+
* superRefine (`settings-manifest.zod.ts`) rejects a manifest that authors one
55+
* of exactly these three types without a non-empty `options`. So "the types
56+
* that must declare an option table" and "the types whose value is checked
57+
* against it" name the same set — declared IS enforced, with no third list to
58+
* drift. `radio` and `multiselect` have no producer manifest today; they are
59+
* covered anyway because the alternative is that the first manifest to author
60+
* one silently re-opens this exact hole.
61+
*/
62+
const OPTION_BEARING_TYPES = new Set(['select', 'radio', 'multiselect']);
63+
64+
/**
65+
* The declared option values, in string form.
66+
*
67+
* String form because a stored value has been through JSON (and, over the REST
68+
* boundary, a form post): an option declared `value: 30` is legitimately read
69+
* back as `'30'`, and rejecting that would be enforcing the transport rather
70+
* than the enumeration. Same rule the record validator applies to
71+
* `select`/`multiselect` field options (objectui#2729).
72+
*/
73+
function declaredOptionValues(options: unknown): string[] {
74+
if (!Array.isArray(options)) return [];
75+
const out: string[] = [];
76+
for (const opt of options) {
77+
if (!opt || typeof opt !== 'object') continue;
78+
const v = (opt as Record<string, unknown>).value;
79+
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
80+
out.push(String(v));
81+
}
82+
}
83+
return out;
84+
}
85+
4986
interface RegisteredManifest {
5087
manifest: SettingsManifest;
5188
/** Resolved specifier scopes for fast lookup. */
@@ -613,8 +650,19 @@ export class SettingsService {
613650
* is (switching provider must validate that provider's fields).
614651
* - `required` + visible + empty → rejected.
615652
* - `pattern` (text fields) + non-empty value that mismatches → rejected.
653+
* - `options` (`select`/`radio`/`multiselect`) + non-empty value outside
654+
* the declared table → rejected (`invalid_option`).
616655
* - All-null patches (namespace reset) and unparseable visibility
617656
* expressions skip validation rather than block the write.
657+
*
658+
* The TOUCH gate is what keeps the options check from being a regression
659+
* for existing workspaces: a value that pre-dates a manifest's current
660+
* option table (a `mail.provider` of `sendgrid`, retired in #5094) only
661+
* fails the patch that writes that key. A patch changing `from_name`
662+
* alone is not rejected because a stale `provider` sits in the store —
663+
* otherwise every workspace carrying historical drift would be locked out
664+
* of its own settings page, unable to edit anything, which is worse than
665+
* the gap this closes.
618666
*/
619667
private async validatePatch(
620668
namespace: string,
@@ -679,6 +727,58 @@ export class SettingsService {
679727
});
680728
continue;
681729
}
730+
731+
// A `select`/`radio`/`multiselect` value must be a member of the option
732+
// table the manifest declares. Until this check existed the `options`
733+
// list was a front-end convention only — the console dropdown emitted
734+
// legal values, but `PUT /api/settings/:ns` accepted any string at all,
735+
// so a script, a migration or AI-authored bootstrap code could write
736+
// `provider: 'sendgrid'` into a namespace that has no such provider and
737+
// the write would succeed silently, leaving each consumer to improvise.
738+
if (!empty && OPTION_BEARING_TYPES.has(type)) {
739+
const allowed = declaredOptionValues(spec.options);
740+
// A manifest with no option table cannot say what is legal. The spec
741+
// refuses that shape at parse time, but `registerManifest` takes
742+
// manifests as given (no Zod pass), so skip rather than reject every
743+
// write to a hand-built manifest — same leniency the unparseable
744+
// `visible` and invalid `pattern` branches already take.
745+
if (allowed.length > 0) {
746+
// `multiselect` stores an array, `select`/`radio` a scalar; both are
747+
// checked element-wise against the one table. A scalar arriving at a
748+
// multiselect is wrapped rather than rejected — policing the value's
749+
// SHAPE is a different constraint (`invalid_type`) with a different
750+
// owner, and inventing it here would reject writes this change was
751+
// never asked to touch.
752+
const picked = Array.isArray(value) ? value : [value];
753+
// `findIndex`, not `find`: a `find` returning `undefined` cannot say
754+
// whether nothing was rejected or whether the rejected element WAS
755+
// `undefined` — and the latter would slip through the check.
756+
const at = picked.findIndex((v) => !allowed.includes(String(v)));
757+
if (at !== -1) {
758+
const offending = picked[at];
759+
// An option value is not a secret, but `encrypted` is authorable on
760+
// any specifier — so never echo the rejected value for a key whose
761+
// contents are held encrypted, in a message that lands in logs.
762+
const secret = reg.encryptedKeys.has(key);
763+
const got = secret ? '' : ` Received '${String(offending)}'.`;
764+
errors.push({
765+
field: key,
766+
code: 'invalid_option',
767+
message: `${label} must be one of: ${allowed.join(', ')}.${got}`,
768+
label,
769+
// The allowed set as a discrete constraint, so a client composes
770+
// its own sentence instead of parsing ours (`FieldError.
771+
// constraint`, ADR-0114). Key and comma-joined form are the ones
772+
// the spec's own `{ allowed: 'draft, sent' }` example documents
773+
// and the record validator already emits for this same code.
774+
constraint: { allowed: allowed.join(', ') },
775+
...(secret ? {} : { value: String(offending) }),
776+
});
777+
continue;
778+
}
779+
}
780+
}
781+
682782
if (!empty && typeof spec.pattern === 'string' && typeof value === 'string') {
683783
let re: RegExp | undefined;
684784
try {

0 commit comments

Comments
 (0)