Skip to content

Commit 6426b86

Browse files
claude[bot]claude
andauthored
fix(service-storage,service-sms): converge the authored-value criterion on ResolvedSettingValue.source (#14135)
A schema default (source: 'default') is not a decision anyone made. The storage swap gate now requires an authored adapter-relevant key before settings may override the constructor-built adapter; the sms downgrade to LogSmsTransport now requires an operator-authored provider selection. Same criterion, same reason as EmailServicePlugin. The sms daily_quota reader keeps its declared by-value exception. Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs Co-authored-by: Claude <noreply@anthropic.com>
1 parent b003cf2 commit 6426b86

5 files changed

Lines changed: 298 additions & 24 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/service-storage": patch
3+
"@objectstack/service-sms": patch
4+
---
5+
6+
fix(service-storage,service-sms): a schema default is not a configuration — converge the "authored value" criterion on `ResolvedSettingValue.source` (#5536)
7+
8+
Both settings-bound plugins decided "has anyone configured this namespace?" by
9+
value presence, but the manifest defaults are non-empty on every boot, so an
10+
unopened settings page read as configuration:
11+
12+
- **service-storage**: the swap gate now requires an adapter-relevant key
13+
(`adapter`, `local_root`, `s3_*` — exactly the inputs `resolveStorageTarget`
14+
reads) whose `source` is not `'default'` before settings may override the
15+
constructor-built adapter. Previously the schema defaults
16+
(`adapter: 'local'`, `local_root: './.objectstack/data/uploads'`) could
17+
silently move a deployment's declared backing store — and an authored save
18+
that touched only, say, the upload limit could open the same door.
19+
- **service-sms**: the downgrade to `LogSmsTransport` now requires an
20+
operator-authored `provider: 'log'` (`source !== 'default'`). Previously
21+
the manifest default `'log'` — a value nobody selected — switched off the
22+
transport the deployment declared via constructor options on every boot.
23+
24+
Same criterion, same reason as `EmailServicePlugin` (the in-repo precedent):
25+
the manifest default (`source: 'default'`) is not a decision anyone made.
26+
Admin-saved rows and env overrides behave exactly as before. A snapshot with
27+
no `source` at all reads as `'default'` — the conservative side keeps the
28+
deployment-declared adapter/transport. The sms `daily_quota` reader keeps its
29+
declared #2814 exception and still binds by value, never by source.

packages/services/service-sms/src/sms-plugin.test.ts

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,34 @@ import { AliyunSmsTransport, TwilioSmsTransport } from './transports/index.js';
99
* Lightweight fake PluginContext (service registry + kernel:ready hooks +
1010
* a fake settings service) — mirrors the messaging plugin's test harness.
1111
*/
12-
function fakeCtx(opts: { settingsValues?: Record<string, unknown> } = {}) {
12+
function fakeCtx(opts: {
13+
settingsValues?: Record<string, unknown>;
14+
/**
15+
* Per-key `ResolvedSettingValue.source`. The real service resolves every
16+
* key to `{ value, source, … }` — a fake that dropped `source` would model
17+
* the exact defect #5536 fixed. Default `'global'` = an admin-saved row,
18+
* which is what every pre-existing test here meant; `'default'` pins the
19+
* schema-default shape and `null` omits the field (a non-conforming
20+
* snapshot).
21+
*/
22+
settingsSources?: Record<string, string | null>;
23+
} = {}) {
1324
const services = new Map<string, unknown>();
1425
const readyHooks: Array<() => Promise<void> | void> = [];
1526
const actions = new Map<string, (input: any) => Promise<any>>();
1627
const subscriptions: Array<{ ns: string; fn: () => void }> = [];
1728
let values = opts.settingsValues;
29+
const sources = opts.settingsSources;
1830

1931
if (values !== undefined) {
2032
services.set('settings', {
2133
async getNamespace(ns: string) {
2234
if (ns !== 'sms') throw new Error('unknown namespace');
23-
const wrapped: Record<string, { value: unknown }> = {};
24-
for (const [k, v] of Object.entries(values ?? {})) wrapped[k] = { value: v };
35+
const wrapped: Record<string, { value: unknown; source?: string }> = {};
36+
for (const [k, v] of Object.entries(values ?? {})) {
37+
const src = sources && k in sources ? sources[k] : 'global';
38+
wrapped[k] = src === null ? { value: v } : { value: v, source: src };
39+
}
2540
return { values: wrapped };
2641
},
2742
subscribe(ns: string, fn: () => void) { subscriptions.push({ ns, fn }); return () => {}; },
@@ -154,6 +169,93 @@ describe('SmsServicePlugin', () => {
154169
});
155170
});
156171

172+
describe('SmsServicePlugin — authored-value criterion (#5536)', () => {
173+
// The two criteria — old "values.provider === 'log'" and new
174+
// "source !== 'default'" — AGREE whenever an operator actually saved the
175+
// row, so these tests sit deliberately on the disagreement: the manifest
176+
// default `'log'`, a non-empty value nobody authored.
177+
const twilioOpts = {
178+
provider: 'twilio' as const,
179+
providerOptions: { accountSid: 'AC1', authToken: 't', from: '+15005550006' },
180+
};
181+
182+
it('keeps the constructor-declared transport when provider is only the schema default', async () => {
183+
// THE card: `provider: 'log'` is non-empty, so the old criterion read it
184+
// as an operator selection and downgraded the deployment's declared
185+
// transport to LogSmsTransport on every boot of an unopened settings
186+
// page; `source: 'default'` says nobody made that decision.
187+
const harness = fakeCtx({
188+
settingsValues: { provider: 'log', daily_quota: 0 },
189+
settingsSources: { provider: 'default', daily_quota: 'default' },
190+
});
191+
const plugin = new SmsServicePlugin(twilioOpts);
192+
await plugin.init(harness.ctx);
193+
await plugin.start(harness.ctx);
194+
await harness.fireReady();
195+
196+
const svc = harness.services.get('sms') as SmsService;
197+
expect(svc.options.transport).toBeInstanceOf(TwilioSmsTransport);
198+
expect(svc.isConfigured()).toBe(true);
199+
});
200+
201+
it('still downgrades when an operator actually saved provider=log (control)', async () => {
202+
// An admin-authored row: both criteria answer "configured", so this pins
203+
// byte-identical behaviour — a declared control, not fix evidence.
204+
const harness = fakeCtx({
205+
settingsValues: { provider: 'log' },
206+
settingsSources: { provider: 'global' },
207+
});
208+
const plugin = new SmsServicePlugin(twilioOpts);
209+
await plugin.init(harness.ctx);
210+
await plugin.start(harness.ctx);
211+
await harness.fireReady();
212+
213+
const svc = harness.services.get('sms') as SmsService;
214+
expect(svc.options.transport).toBeInstanceOf(LogSmsTransport);
215+
expect(svc.isConfigured()).toBe(false);
216+
});
217+
218+
it('a snapshot with no source at all keeps the declared transport (reverse control)', async () => {
219+
// A non-conforming snapshot (no `source` anywhere) must land on the
220+
// conservative side, and the conservative side here KEEPS the transport
221+
// the deployment declared delivering: the other side's failure mode is
222+
// silently routing every send to the log transport on unattributable
223+
// authorship. An operator can still downgrade explicitly — a real save
224+
// always carries `source`.
225+
const harness = fakeCtx({
226+
settingsValues: { provider: 'log' },
227+
settingsSources: { provider: null },
228+
});
229+
const plugin = new SmsServicePlugin(twilioOpts);
230+
await plugin.init(harness.ctx);
231+
await plugin.start(harness.ctx);
232+
await harness.fireReady();
233+
234+
const svc = harness.services.get('sms') as SmsService;
235+
expect(svc.options.transport).toBeInstanceOf(TwilioSmsTransport);
236+
});
237+
238+
it('daily_quota still binds by VALUE at source=default — the #2814 declination stands', async () => {
239+
// `sms-plugin.ts` deliberately declines to read `source` for the daily
240+
// cost ceiling (its comment cites #5536 by number): env-locked,
241+
// admin-saved and defaulted quotas are the same instruction to that
242+
// reader. Pinned so the authored-value criterion never creeps into the
243+
// quota path.
244+
const harness = fakeCtx({
245+
settingsValues: { provider: 'log', daily_quota: 1 },
246+
settingsSources: { provider: 'default', daily_quota: 'default' },
247+
});
248+
const plugin = new SmsServicePlugin();
249+
await plugin.init(harness.ctx);
250+
await plugin.start(harness.ctx);
251+
await harness.fireReady();
252+
253+
const svc = harness.services.get('sms') as SmsService;
254+
expect((await svc.send({ to: '+8613800000001', body: 'a' })).status).toBe('sent');
255+
expect((await svc.send({ to: '+8613800000002', body: 'b' })).status).toBe('failed');
256+
});
257+
});
258+
157259
describe('SmsServicePlugin — daily quota binding (#2814)', () => {
158260
it('applies sms.daily_quota from settings at kernel:ready', async () => {
159261
const harness = fakeCtx({ settingsValues: { provider: 'log', daily_quota: 2 } });

packages/services/service-sms/src/sms-plugin.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -226,8 +226,10 @@ export class SmsServicePlugin implements Plugin {
226226
try {
227227
const payload = await settings.getNamespace('sms');
228228
const values: Record<string, unknown> = {};
229+
const sources: Record<string, string> = {};
229230
for (const [k, v] of Object.entries(payload.values as Record<string, any>)) {
230231
values[k] = v?.value;
232+
if (v?.source) sources[k] = String(v.source);
231233
}
232234
// #2814 — the daily cost ceiling binds for EVERY composition,
233235
// including a host-injected transport: "how much may this
@@ -239,7 +241,7 @@ export class SmsServicePlugin implements Plugin {
239241
this.dailyQuota?.setQuota(values.daily_quota);
240242
// A host-injected transport, by contrast, IS authoritative — the
241243
// settings form only manages the provider-tag path.
242-
if (!this.options.transport) this.applySmsSettings(values, ctx);
244+
if (!this.options.transport) this.applySmsSettings(values, sources, ctx);
243245
} catch (err: any) {
244246
ctx.logger.warn('SmsServicePlugin: failed to apply sms settings: ' + (err?.message ?? err));
245247
}
@@ -313,7 +315,11 @@ export class SmsServicePlugin implements Plugin {
313315
* on the running SmsService. Incomplete credentials keep the previous
314316
* transport (with a warning) so a half-saved form can't break delivery.
315317
*/
316-
private applySmsSettings(values: Record<string, unknown>, ctx: PluginContext): void {
318+
private applySmsSettings(
319+
values: Record<string, unknown>,
320+
sources: Record<string, string>,
321+
ctx: PluginContext,
322+
): void {
317323
if (!this.service) return;
318324
const resolved = providerFromSettings(values);
319325
if (resolved.missing) {
@@ -323,9 +329,19 @@ export class SmsServicePlugin implements Plugin {
323329
return;
324330
}
325331
if (resolved.provider === 'log') {
326-
// Downgrade to the dev transport only when the operator explicitly
327-
// selected `log`; an unset namespace keeps the constructor opts.
328-
if (values.provider === 'log') {
332+
// [#5536] Downgrade to the dev transport only when an operator (or an
333+
// env override) actually AUTHORED the `log` selection —
334+
// `values.provider === 'log'` alone cannot tell that apart from the
335+
// manifest default, which resolves to `'log'` on every boot whether or
336+
// not anyone ever opened the settings page. Same criterion, same
337+
// reason as EmailServicePlugin: the manifest default
338+
// (`source: 'default'`) is not a decision anyone made, and treating it
339+
// as one would let a settings page nobody opened silently switch off
340+
// the transport the deployment declared via constructor options. A
341+
// missing `source` (a non-conforming snapshot) reads as 'default': the
342+
// conservative side keeps the deployment's declared transport
343+
// delivering.
344+
if (values.provider === 'log' && (sources.provider ?? 'default') !== 'default') {
329345
this.service.setTransport(new LogSmsTransport(ctx.logger), false);
330346
ctx.logger.info('SmsServicePlugin: sms settings applied (provider=log; SMS will NOT be sent).');
331347
}

packages/services/service-storage/src/storage-service-plugin.test.ts

Lines changed: 111 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,29 @@ function makeCtx() {
4343
return ctx;
4444
}
4545

46-
function makeFakeSettings(initialValues: Record<string, any>) {
46+
// The real service resolves every key to a full `ResolvedSettingValue`
47+
// (`{ value, source, … }`) — a fake that dropped `source` would model the
48+
// exact defect #5536 fixed. Default `'global'` = an admin-saved row, which is
49+
// what every pre-existing test here meant; the `sources` overrides pin the
50+
// schema-default (`'default'`) and missing-source (`null` → key omitted)
51+
// shapes per key.
52+
function makeFakeSettings(
53+
initialValues: Record<string, any>,
54+
initialSources?: Record<string, string | null>,
55+
) {
4756
let values = { ...initialValues };
57+
const sources = initialSources;
4858
const subs: Array<(ns: string) => void> = [];
4959
const actions = new Map<string, (input: any) => Promise<any>>();
5060
return {
5161
set values(v: Record<string, any>) { values = v; },
5262
get values() { return values; },
5363
createClient: (_ns: string) => ({}),
5464
getNamespace: async (_ns: string) => ({
55-
values: Object.fromEntries(Object.entries(values).map(([k, v]) => [k, { value: v }])),
65+
values: Object.fromEntries(Object.entries(values).map(([k, v]) => {
66+
const src = sources && k in sources ? sources[k] : 'global';
67+
return [k, src === null ? { value: v } : { value: v, source: src }];
68+
})),
5669
}),
5770
subscribe: (ns: string, fn: () => void) => { subs.push((n) => { if (n === ns) fn(); }); },
5871
registerAction: (ns: string, id: string, fn: (input: any) => Promise<any>) => {
@@ -101,6 +114,9 @@ describe('StorageServicePlugin: settings live-wire', () => {
101114
expect(alias).toBe(canonical);
102115
});
103116

117+
// #5536 control: an admin-saved row (`source: 'global'`, the fake's
118+
// default) is "configured" under BOTH the old value-presence criterion and
119+
// the new authored-source one — behaviour must stay byte-identical.
104120
it('swaps the inner adapter when storage settings change', async () => {
105121
const dirA = await fs.mkdtemp(join(tmpdir(), 'oss-a-'));
106122
const dirB = await fs.mkdtemp(join(tmpdir(), 'oss-b-'));
@@ -150,12 +166,101 @@ describe('StorageServicePlugin: settings live-wire', () => {
150166
expect(proxy.getInner()).toBe(before);
151167
});
152168

169+
// ── #5536: only an AUTHORED value may move the backing store ─────────────
170+
//
171+
// The two criteria — old "any value non-empty" and new
172+
// "source !== 'default'" — AGREE whenever an admin actually saved a value,
173+
// so the tests below sit deliberately on the disagreement: a non-empty
174+
// value whose source says nobody authored it.
175+
176+
it('keeps the constructor adapter when every value is a schema default (#5536)', async () => {
177+
const dirA = await fs.mkdtemp(join(tmpdir(), 'oss-default-'));
178+
const plugin = new StorageServicePlugin({
179+
adapter: 'local',
180+
local: { rootDir: dirA },
181+
registerRoutes: false,
182+
});
183+
const ctx = makeCtx();
184+
// Non-empty values (the manifest defaults) that nobody ever authored:
185+
// the old "value present" criterion read this as configured and swapped
186+
// the deployment's adapter to the schema default; `source: 'default'`
187+
// says no one made that decision.
188+
const settings = makeFakeSettings(
189+
{ adapter: 'local', local_root: './.objectstack/data/uploads', max_upload_mb: 100 },
190+
{ adapter: 'default', local_root: 'default', max_upload_mb: 'default' },
191+
);
192+
ctx.registerService('settings', settings);
193+
194+
await plugin.init(ctx);
195+
await plugin.start(ctx);
196+
const proxy = ctx.getService('storage') as SwappableStorageService;
197+
const before = proxy.getInner();
198+
await ctx._flushReady();
199+
expect(proxy.getInner()).toBe(before);
200+
expect(ctx._logs.warn.join('\n')).not.toContain('adapter swapped');
201+
});
202+
203+
it('an authored save that touched no adapter key does not open the swap gate (#5536)', async () => {
204+
// The criterion is per-decision, exactly as plugin-email applies it per
205+
// key: an admin who saved only the upload limit did not author the
206+
// schema-default adapter, so the constructor's store stays.
207+
const dirA = await fs.mkdtemp(join(tmpdir(), 'oss-limits-'));
208+
const plugin = new StorageServicePlugin({
209+
adapter: 'local',
210+
local: { rootDir: dirA },
211+
registerRoutes: false,
212+
});
213+
const ctx = makeCtx();
214+
const settings = makeFakeSettings(
215+
{ adapter: 'local', local_root: './.objectstack/data/uploads', max_upload_mb: 50 },
216+
{ adapter: 'default', local_root: 'default', max_upload_mb: 'global' },
217+
);
218+
ctx.registerService('settings', settings);
219+
220+
await plugin.init(ctx);
221+
await plugin.start(ctx);
222+
const proxy = ctx.getService('storage') as SwappableStorageService;
223+
const before = proxy.getInner();
224+
await ctx._flushReady();
225+
expect(proxy.getInner()).toBe(before);
226+
});
227+
228+
it('a snapshot with no source at all lands on the no-swap side (#5536 reverse control)', async () => {
229+
// An un-upgraded / non-conforming settings implementation that omits
230+
// `source` must land on the conservative side, and the conservative side
231+
// here is the one that does NOT move the backing store on unattributable
232+
// authorship: files stay reachable through the adapter the deployment
233+
// declared, and a real admin save (which always carries `source`) can
234+
// still trigger the swap via the subscription.
235+
const dirA = await fs.mkdtemp(join(tmpdir(), 'oss-nosrc-a-'));
236+
const dirB = await fs.mkdtemp(join(tmpdir(), 'oss-nosrc-b-'));
237+
const plugin = new StorageServicePlugin({
238+
adapter: 'local',
239+
local: { rootDir: dirA },
240+
registerRoutes: false,
241+
});
242+
const ctx = makeCtx();
243+
const settings = makeFakeSettings(
244+
{ adapter: 'local', local_root: dirB },
245+
{ adapter: null, local_root: null },
246+
);
247+
ctx.registerService('settings', settings);
248+
249+
await plugin.init(ctx);
250+
await plugin.start(ctx);
251+
const proxy = ctx.getService('storage') as SwappableStorageService;
252+
const before = proxy.getInner();
253+
await ctx._flushReady();
254+
expect(proxy.getInner()).toBe(before);
255+
});
256+
153257
// ── #4096: the swap decision, and what it says out loud ──────────────────
154258
//
155-
// `hasAny` is true on every boot once the settings service has persisted its
156-
// own defaults, so this used to rebuild and swap the adapter unconditionally
157-
// and warn that "existing files were NOT migrated" — about a swap from an
158-
// adapter to an identically-configured one, on a healthy server, forever.
259+
// The old value-presence gate (now the #5536 authored gate) was true on
260+
// every boot once an admin save had persisted the namespace, so this used
261+
// to rebuild and swap the adapter unconditionally and warn that "existing
262+
// files were NOT migrated" — about a swap from an adapter to an
263+
// identically-configured one, on a healthy server, forever.
159264

160265
it('neither swaps nor warns when persisted settings match the running adapter', async () => {
161266
const dir = await fs.mkdtemp(join(tmpdir(), 'oss-same-'));

0 commit comments

Comments
 (0)