From 4246cad117e14936111f1af092963886f9f6c707 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 20:43:15 +0000 Subject: [PATCH 1/6] fix(service-settings)!: a json setting persists a JSON object or array, and the console sends one The console's json field sent the textarea's text and the settings save door stored it, so Data Lifecycle quotas and retention overrides were saved as strings and the lifecycle sweep read them as absent. - service-settings: validatePatch refuses a json value that is not a JSON object or array (invalid_type), and the env door rejects such an OS_* override; one helper (jsonShapeMiss) for both doors. - objectql: LifecycleService reads a map setting stored as JSON text as the object it holds, with one warning per stored value; a value that is not an object is reported once at error. - console: the json field's text is parsed at Save; text that does not parse blocks Save on that row. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dxcqiQvzKhr5odTPe --- .../31241-json-setting-persists-object.md | 22 ++ ...ecycle-service.governance-map-text.test.ts | 160 ++++++++++ .../src/lifecycle/lifecycle-service.ts | 82 ++++- .../src/settings-json-shape-31241.test.ts | 289 ++++++++++++++++++ .../service-settings/src/settings-service.ts | 80 +++++ .../src/pages/settings/SettingsView.tsx | 60 +++- ...ettingsView.jsonSendsObject-31241.test.tsx | 144 +++++++++ 7 files changed, 832 insertions(+), 5 deletions(-) create mode 100644 .changeset/31241-json-setting-persists-object.md create mode 100644 packages/objectql/src/lifecycle/lifecycle-service.governance-map-text.test.ts create mode 100644 packages/services/service-settings/src/settings-json-shape-31241.test.ts create mode 100644 ui/apps/console/src/pages/settings/__tests__/SettingsView.jsonSendsObject-31241.test.tsx diff --git a/.changeset/31241-json-setting-persists-object.md b/.changeset/31241-json-setting-persists-object.md new file mode 100644 index 00000000000..a9fb36d42c6 --- /dev/null +++ b/.changeset/31241-json-setting-persists-object.md @@ -0,0 +1,22 @@ +--- +'@objectstack/service-settings': patch +'@objectstack/objectql': patch +'@object-ui/console': patch +--- + +fix(service-settings)!: a `json` setting persists a JSON object or array, and the console sends one; data lifecycle quotas and retention overrides saved as text are read and applied + +Clause-②: yes (narrowing) — the settings save door and the env door start refusing a `json` value that is not a JSON object or array, which they accept today; ADR-0007:123 already declares `json` persists an object, so the narrowing enforces the declared contract and widens nothing +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing at the settings doors (`@objectstack/service-settings`): a `json` key takes a JSON object or array, as ADR-0007 declares. + +- **Before.** `PUT /api/settings/:namespace` stored any value under a `json` key. The console's `json` field sent the textarea's text, so Setup → Data Lifecycle saved `lifecycle.quotas`, `lifecycle.quota_defaults` and `lifecycle.retention_overrides` as strings, answered "saved", and the lifecycle sweep read every quota and override as absent: no `quota-exceeded` alert, no override applied, nothing logged. +- **Now.** The save door refuses a string, number or boolean under a `json` key with `400 SETTINGS_VALIDATION` and one `invalid_type` field error for the key, and stores nothing. An object, an array and `null` (a clear) are accepted. A value already stored under a key the save does not touch does not block it. An `OS_*` override for a `json` key whose text does not parse to an object or array is not in force: one `error` line names the variable, and the key resolves from the next layer of the cascade. +- **Who moves.** The console now parses the text at Save: valid JSON is sent as the object or array, blank text as a clear, and text that does not parse blocks Save on that row with "Invalid JSON". A script that PUTs JSON text for a `json` key gets the 400. + +**The remedy.** Send the parsed value, not its text: `{ "quotas": { "sys_audit_log": 1000 } }`, not `{ "quotas": "{\"sys_audit_log\": 1000}" }`. For an `OS_*` override, set JSON text of an object, such as `OS_LIFECYCLE_QUOTAS='{"sys_audit_log":1000}'`. + +**Values saved as text before this release** (`@objectstack/objectql`). A deployment that saved the Data Lifecycle page through the old console holds JSON text. The lifecycle service now reads text that holds a JSON object as that object and applies it, and logs one warning per stored value naming the key: `lifecycle.quotas is stored as JSON text, not an object … Save the Data Lifecycle settings page again to store it as an object.` So quotas and retention overrides saved earlier start being enforced on the first sweep after the upgrade, with no migration. Check them before you upgrade: a retention override that never applied before will now delete or keep rows on its window. A stored value that is not a JSON object still applies nothing, as before, and is now reported once at `error`. diff --git a/packages/objectql/src/lifecycle/lifecycle-service.governance-map-text.test.ts b/packages/objectql/src/lifecycle/lifecycle-service.governance-map-text.test.ts new file mode 100644 index 00000000000..0df5550580c --- /dev/null +++ b/packages/objectql/src/lifecycle/lifecycle-service.governance-map-text.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#31241] A map-valued `lifecycle` setting stored as JSON TEXT is read as the + * map it holds. + * + * `retention_overrides`, `quotas` and `quota_defaults` are `json` specifiers, + * which persist an object (ADR-0007). The console's `json` field used to send + * the textarea's text and the settings save door stored it, so a deployment + * that saved the Data Lifecycle page holds a string where the map belongs. + * `loadGovernance` indexed that string, so every override and quota read as + * absent, with nothing logged. The save door now refuses a string + * (`service-settings`), but rows already stored are not re-judged: this reader + * parses a string that holds a JSON object, applies it and warns once per + * stored value; anything else that is not a plain object is ignored as before, + * now said once at `error`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { LifecycleService, type LifecycleObjectLike } from './lifecycle-service.js'; +import { parseLifecycleDuration } from './duration.js'; + +const FIXED_NOW = 1_700_000_000_000; +const isoCutoff = (literal: string) => new Date(FIXED_NOW - parseLifecycleDuration(literal)).toISOString(); + +const JOB_RUN: LifecycleObjectLike = { + name: 'sys_job_run', + lifecycle: { class: 'telemetry', retention: { maxAge: '30d' } } as any, +}; + +/** Settings double: a value map at the global rung, plus per-organization rows. */ +function settingsOf(global: Record, tenant: Record> = {}) { + return { + async get(_ns: string, key: string, ctx?: Record) { + const org = ctx?.tenantId as string | undefined; + if (org && tenant[org] && key in tenant[org]) return { value: tenant[org][key], source: 'tenant' }; + if (key in global) return { value: global[key], source: 'global' }; + return { value: undefined, source: 'default' }; + }, + }; +} + +/** Engine double: one declared object, a counting driver, captured deletes and candidate reads. */ +function engineOf(rows = 1_500, organizations: string[] = []) { + const deletes: Array<{ where: unknown }> = []; + const reads: Array<{ object: string; where: unknown }> = []; + const driver = { name: 'default', count: vi.fn(async () => rows) }; + const engine: any = { + registry: { getAllObjects: () => [JOB_RUN] }, + async delete(_object: string, options: any) { + deletes.push({ where: options?.where }); + return { deletedCount: 1 }; + }, + getDriverForObject: () => driver, + datasource() { + throw new Error('no datasource'); + }, + }; + if (organizations.length > 0) { + engine.find = async (object: string, options: any) => { + if (object === 'sys_organization') return organizations.map((id) => ({ id })); + reads.push({ object, where: options?.where }); + const org = (options?.where as Record | undefined)?.organization_id; + return [{ id: typeof org === 'string' ? `${org}_row` : 'global_row' }]; + }; + } + return { engine, deletes, reads }; +} + +function serviceOf(engine: unknown, settings: unknown) { + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const svc = new LifecycleService({ + getEngine: () => engine as any, + getSettings: () => settings as any, + logger, + now: () => FIXED_NOW, + initialDelayMs: 1, + sweepIntervalMs: 10, + onAlert: () => {}, + }); + return { svc, logger }; +} + +describe('[#31241] a map setting stored as JSON text', () => { + it('CONTROL: quotas stored as an object alert, and nothing is logged about them', async () => { + const { engine } = engineOf(1_500); + const { svc, logger } = serviceOf(engine, settingsOf({ quotas: { sys_job_run: 1_000 } })); + + const report = await svc.sweep(); + + expect(report.alerts).toEqual([{ type: 'quota-exceeded', object: 'sys_job_run', rowCount: 1_500, quota: 1_000 }]); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('quotas stored as the text the old console saved are applied, with one warning naming the key', async () => { + const { engine } = engineOf(1_500); + const { svc, logger } = serviceOf(engine, settingsOf({ quotas: '{\n "sys_job_run": 1000\n}' })); + + const first = await svc.sweep(); + const second = await svc.sweep(); + + expect(first.alerts).toEqual([{ type: 'quota-exceeded', object: 'sys_job_run', rowCount: 1_500, quota: 1_000 }]); + expect(second.alerts).toEqual(first.alerts); + // Once per stored value, not once per sweep. + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn.mock.calls[0][0]).toContain('lifecycle.quotas is stored as JSON text, not an object'); + expect(logger.warn.mock.calls[0][0]).toContain('Save the Data Lifecycle settings page again'); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('a class default stored as text applies through quota_defaults', async () => { + const { engine } = engineOf(1_500); + const { svc } = serviceOf(engine, settingsOf({ quota_defaults: '{"telemetry": 500}' })); + + const report = await svc.sweep(); + + expect(report.alerts).toEqual([{ type: 'quota-exceeded', object: 'sys_job_run', rowCount: 1_500, quota: 500 }]); + }); + + it('a global retention override stored as text moves the window', async () => { + const { engine, deletes } = engineOf(); + const { svc, logger } = serviceOf(engine, settingsOf({ retention_overrides: '{"sys_job_run": {"maxAge": "90d"}}' })); + + await svc.sweep(); + + expect(deletes).toEqual([{ where: { created_at: { $lt: isoCutoff('90d') } } }]); + expect(logger.warn.mock.calls[0][0]).toContain('lifecycle.retention_overrides is stored as JSON text'); + }); + + it("an organization's retention override stored as text runs that organization's pass on it", async () => { + const { engine, reads } = engineOf(1_500, ['org_reg', 'org_plain']); + const { svc, logger } = serviceOf( + engine, + settingsOf({}, { org_reg: { retention_overrides: '{"sys_job_run": {"maxAge": "2y"}}' } }), + ); + + await svc.sweep(); + + const passes = reads.filter((r) => r.object === 'sys_job_run').map((r) => r.where); + expect(passes).toEqual([ + { created_at: { $lt: isoCutoff('2y') }, organization_id: 'org_reg' }, + { created_at: { $lt: isoCutoff('30d') }, organization_id: { $nin: ['org_reg'] } }, + ]); + expect(logger.warn.mock.calls[0][0]).toContain("organization org_reg's lifecycle.retention_overrides"); + }); + + it('text that is not a JSON object applies nothing, and says so once at error', async () => { + const { engine } = engineOf(1_500); + const { svc, logger } = serviceOf(engine, settingsOf({ quotas: '{ "sys_job_run": 1000' })); + + const first = await svc.sweep(); + await svc.sweep(); + + expect(first.alerts).toEqual([]); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error.mock.calls[0][0]).toContain('lifecycle.quotas is not an object'); + expect(logger.error.mock.calls[0][0]).not.toContain('sys_job_run'); + }); +}); diff --git a/packages/objectql/src/lifecycle/lifecycle-service.ts b/packages/objectql/src/lifecycle/lifecycle-service.ts index ba3f2b02a37..9cc08b54a02 100644 --- a/packages/objectql/src/lifecycle/lifecycle-service.ts +++ b/packages/objectql/src/lifecycle/lifecycle-service.ts @@ -275,6 +275,20 @@ const DEFAULT_GOVERNANCE: GovernanceSnapshot = { growthAlertRows: 0, }; +/** + * [#31241] The `lifecycle` settings this service indexes as maps — the `json` + * specifiers of `lifecycleSettingsManifest` — read through + * {@link LifecycleService.governanceMap}. + */ +const GOVERNANCE_MAP_KEYS: ReadonlySet = new Set(['retention_overrides', 'quotas', 'quota_defaults']); + +/** A plain object (not an array, not a class instance): the shape a map setting persists. */ +function isPlainRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + /** Cap on tenants scanned for per-tenant overrides each sweep. */ const TENANT_SCAN_LIMIT = 200; @@ -507,6 +521,12 @@ export class LifecycleService { private sweeping = false; /** Row counts from the previous sweep — baseline for growth alerts. */ private lastCounts = new Map(); + /** + * [#31241] Map-valued settings already said about, `key|organization|text`, + * so {@link governanceMap} warns once per stored value rather than once per + * sweep. + */ + private readonly governanceMapSaid = new Set(); /** Governance snapshot for the sweep in flight. */ private governance: GovernanceSnapshot = DEFAULT_GOVERNANCE; /** @@ -1126,6 +1146,63 @@ export class LifecycleService { * * [#21597] The scan asks the registry before it reads. An unregistered * `sys_organization` is never read, and answers "no tenant overrides". */ + /** + * [#31241] The value of one map-valued `lifecycle` setting + * (`retention_overrides`, `quotas`, `quota_defaults`) as the map this service + * indexes, or `undefined` for "nothing configured". + * + * These keys are `json` specifiers, which persist an object (ADR-0007). Until + * #31241 the console's `json` field sent the textarea's TEXT and the settings + * save door stored it, so a deployment that saved this page holds a JSON + * string where the map belongs; indexing that string reads every override and + * quota as absent, with nothing logged. The save door refuses a string now, + * but rows already stored are not re-judged, so this reader repairs them in + * place rather than through a migration: a string that parses to a plain + * object IS the map its author typed, and it is applied, with one `warn` per + * stored value naming the key and the one-step fix (save the page again). + * + * Anything else that is not a plain object (text that does not parse, a + * scalar, an array) cannot be applied. That was already the outcome; what + * changes is that it is now said, once, at `error`: the setting looks saved + * from the outside while the platform enforces the declared windows instead. + */ + private governanceMap(key: string, value: unknown, tenantId?: string): unknown { + if (value === null || typeof value === 'undefined') return undefined; + if (isPlainRecord(value)) return value; + const where = tenantId ? `organization ${tenantId}'s ` : ''; + const said = `${key}|${tenantId ?? ''}|${typeof value === 'string' ? value : typeof value}`; + const first = !this.governanceMapSaid.has(said); + if (first) this.governanceMapSaid.add(said); + if (typeof value === 'string') { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + parsed = undefined; + } + if (isPlainRecord(parsed)) { + if (first) { + this.opts.logger.warn( + `[lifecycle] ${where}lifecycle.${key} is stored as JSON text, not an object ` + + '(saved by a console that sent the text). It is read as the object it holds and applied. ' + + 'Save the Data Lifecycle settings page again to store it as an object.', + ); + } + return parsed; + } + } + if (first) { + const log = this.opts.logger.error ?? this.opts.logger.warn; + log.call( + this.opts.logger, + `[lifecycle] ${where}lifecycle.${key} is not an object (${typeof value === 'string' ? `a string of ${value.length} characters that is not a JSON object` : Array.isArray(value) ? 'an array' : `a ${typeof value}`}), ` + + 'so none of it applies: the declared lifecycle windows and no quotas are enforced instead. ' + + 'Set it again on the Data Lifecycle settings page.', + ); + } + return undefined; + } + private async loadGovernance( engine: LifecycleEngineLike, declared: LifecycleObjectLike[], @@ -1136,7 +1213,10 @@ export class LifecycleService { const read = async (key: string, fallback: T, ctx?: Record): Promise<{ value: T; source?: string }> => { try { const r = await settings.get('lifecycle', key, ctx); - return { value: (r?.value ?? fallback) as T, source: r?.source }; + const value = GOVERNANCE_MAP_KEYS.has(key) + ? this.governanceMap(key, r?.value, r?.source === 'tenant' ? (ctx?.tenantId as string | undefined) : undefined) + : r?.value; + return { value: (value ?? fallback) as T, source: r?.source }; } catch { return { value: fallback }; } diff --git a/packages/services/service-settings/src/settings-json-shape-31241.test.ts b/packages/services/service-settings/src/settings-json-shape-31241.test.ts new file mode 100644 index 00000000000..e1a8bab70b3 --- /dev/null +++ b/packages/services/service-settings/src/settings-json-shape-31241.test.ts @@ -0,0 +1,289 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#31241] A `json` setting persists a JSON object or array (ADR-0007: + * `json` | code editor | object), at both doors. + * + * The console's `json` field sent the textarea's TEXT, and the save door + * stored it: `PUT /api/settings/lifecycle` with `{ quotas: "{ … }" }` answered + * 200 and `sys_platform_setting.value` held a string, so the lifecycle sweep + * read every quota as absent. Pinned here over the real `ObjectQL` engine, the + * real platform setting objects and the real route, with the shipped + * `lifecycleSettingsManifest` (the only `json` specifiers in the platform): + * + * - the save door refuses a string — and any other non-object — for a `json` + * key with one `invalid_type` `FieldError`, and stores nothing; + * - an object, an array and a clear (`null`) are accepted; + * - a string already stored under one `json` key does not block a save that + * does not touch it (the door judges only what it is handed); + * - the env door (`OS_LIFECYCLE_*`) rejects text that does not parse to an + * object or array, with one `error` line, and the key resolves from the next + * layer; JSON text of an object is in force. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectQL, lifecycleSettingsManifest } from '@objectstack/objectql'; +import { SysPlatformSetting, SysSecret, SysSetting } from '@objectstack/platform-objects/system'; +import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +import { SettingsService } from './settings-service.js'; +import { SettingsServicePlugin, wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; +import { withEngineRegistry } from './__tests__/settings-organization.testkit.js'; +import { LocalCryptoProvider } from './local-crypto-provider.js'; +import { registerSettingsRoutes } from './settings-routes.js'; + +const OWNER_PACKAGE = 'com.objectstack.test.settings-json-shape'; + +/** A driver over plain Maps — the verbs the settings path reaches through `ObjectQL`. */ +function makeMemoryDriver() { + const store = new Map>>(); + let nextId = 0; + const copy = (r: Record) => ({ ...r }); + const rowsOf = (object: string) => { + let s = store.get(object); + if (!s) { s = new Map(); store.set(object, s); } + return s; + }; + // `$or` is the one combinator the settings reads emit; anything else refuses, + // so a predicate this double cannot express never reads as "no such row". + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]) => { + if (k === '$or') return (v as any[]).some((b) => matches(row, b)); + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return (row[k] ?? null) === (v ?? null); + }); + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + // The caller's bound by PRESENCE, after the filter and before the copy. + const hits = [...rowsOf(object).values()].filter((r) => matches(r, ast?.where)); + const page = typeof ast?.limit === 'number' ? hits.slice(0, ast.limit) : hits; + return page.map(copy); + }, + async findOne(object: string, ast: any) { + for (const r of rowsOf(object).values()) if (matches(r, ast?.where)) return copy(r); + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `row_${nextId}`; + const row = { ...data, id }; + rowsOf(object).set(id, row); + return copy(row); + }, + async update(object: string, id: string, data: Record) { + const s = rowsOf(object); + const cur = s.get(id); + if (!cur) return null; + const next = { ...cur, ...data, id }; + s.set(id, next); + return copy(next); + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + return id && rowsOf(object).has(id) ? this.update(object, id, data) : this.create(object, data); + }, + async delete(object: string, id: string) { return rowsOf(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + async updateMany(object: string, ast: any, data: Record) { + const rows = await this.find(object, ast); + const s = rowsOf(object); + for (const r of rows) s.set(r.id as string, { ...s.get(r.id as string), ...data, id: r.id }); + return rows.length; + }, + async deleteMany(object: string, ast: any) { + const rows = await this.find(object, ast); + for (const r of rows) rowsOf(object).delete(r.id as string); + return rows.length; + }, + async syncSchema() {}, async dropTable() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, rowsOf }; +} + +class MockHttp implements IHttpServer { + routes = new Map(); + private add(method: string, path: string, h: RouteHandler) { this.routes.set(`${method} ${path}`, h); } + get(p: string, h: RouteHandler) { this.add('GET', p, h); return this as any; } + post(p: string, h: RouteHandler) { this.add('POST', p, h); return this as any; } + put(p: string, h: RouteHandler) { this.add('PUT', p, h); return this as any; } + delete(p: string, h: RouteHandler) { this.add('DELETE', p, h); return this as any; } + patch(p: string, h: RouteHandler) { this.add('PATCH', p, h); return this as any; } + use() { return this as any; } + listen() { return Promise.resolve(); } + close() { return Promise.resolve(); } + getInstance() { return null; } +} + +/** The pieces the running server binds, with the shipped lifecycle manifest and the settings routes. */ +async function boot() { + const engine = new ObjectQL(); + const { driver, rowsOf } = makeMemoryDriver(); + engine.registerDriver(driver, true); + await engine.init(); + for (const o of [SysSetting, SysPlatformSetting, SysSecret]) { + engine.registry.registerObject(o as any, OWNER_PACKAGE); + } + const svc = new SettingsService({ + env: {}, + engine: withEngineRegistry(wrapEngineAsSettingsEngine(engine as any), engine), + cryptoProvider: new LocalCryptoProvider(), + secretStore: (new SettingsServicePlugin() as any).buildSecretStore(engine), + }); + svc.registerManifest(lifecycleSettingsManifest); + const http = new MockHttp(); + registerSettingsRoutes(http, svc, { + contextFromRequest: () => ({ enforced: true, permissions: ['manage_platform_settings'], userId: 'usr_admin' }), + }); + const put = http.routes.get('PUT /api/settings/:namespace')!; + /** One `PUT /api/settings/lifecycle` with `body`, as the console sends it (a flat patch). */ + const save = async (body: Record) => { + const req: IHttpRequest = { params: { namespace: 'lifecycle' }, query: {}, body, headers: {}, method: 'PUT', path: '/' }; + const state: { status: number; body?: any } = { status: 200 }; + const res: IHttpResponse = { + json: vi.fn((d) => { state.body = d; }) as any, + send: vi.fn() as any, + status: vi.fn((c: number) => { state.status = c; return res; }) as any, + header: vi.fn(() => res) as any, + }; + await put(req, res); + return state; + }; + const stored = (key: string) => + [...rowsOf('sys_platform_setting').values()].find((r) => r.namespace === 'lifecycle' && r.key === key); + const seed = (row: Record) => + rowsOf('sys_platform_setting').set(String(row.id), { encrypted: false, locked: false, value_enc: null, ...row }); + return { svc, save, stored, seed }; +} + +describe('[#31241] the save door: a `json` key takes a JSON object or array', () => { + it('refuses the JSON text the old console sent: 400, one invalid_type FieldError per key, nothing stored', async () => { + const { save, stored } = await boot(); + + const st = await save({ + quotas: '{\n "sys_audit_log": 1000\n}', + quota_defaults: '{\n "telemetry": 500\n}', + }); + + expect(st.status).toBe(400); + expect(st.body.error.code).toBe('SETTINGS_VALIDATION'); + const fields = st.body.error.details.fields as Array>; + expect(fields.map((f) => [f.field, f.code])).toEqual([ + ['quotas', 'invalid_type'], + ['quota_defaults', 'invalid_type'], + ]); + expect(fields[0].constraint).toEqual({ type: 'json' }); + expect(fields[0].message).toBe( + 'Row quotas must be a JSON object or array, not a string of 27 characters. Send the parsed value, not its JSON text.', + ); + // The message names what to send, never the value. + expect(JSON.stringify(fields)).not.toContain('sys_audit_log'); + expect(stored('quotas')).toBeUndefined(); + expect(stored('quota_defaults')).toBeUndefined(); + }); + + it('refuses every other non-object: a number, a boolean, a blank string', async () => { + const { save, stored } = await boot(); + + const st = await save({ quotas: 5, quota_defaults: true, retention_overrides: '' }); + + expect(st.status).toBe(400); + const fields = st.body.error.details.fields as Array>; + expect(fields.map((f) => [f.field, f.code])).toEqual([ + ['retention_overrides', 'invalid_type'], + ['quotas', 'invalid_type'], + ['quota_defaults', 'invalid_type'], + ]); + expect(stored('quotas')).toBeUndefined(); + }); + + it('CONTROL: stores an object and an array as themselves', async () => { + const { svc, save, stored } = await boot(); + + const st = await save({ quotas: { sys_audit_log: 1000 }, quota_defaults: [] }); + + expect(st.status).toBe(200); + expect(stored('quotas')?.value).toEqual({ sys_audit_log: 1000 }); + expect(stored('quota_defaults')?.value).toEqual([]); + const got = await svc.get('lifecycle', 'quotas'); + expect(got.value).toEqual({ sys_audit_log: 1000 }); + expect(got.source).toBe('global'); + }); + + it('accepts a clear (null): the key falls back to its default', async () => { + const { svc, save } = await boot(); + await save({ quotas: { sys_audit_log: 1000 } }); + + const st = await save({ quotas: null }); + + expect(st.status).toBe(200); + expect((await svc.get('lifecycle', 'quotas')).value).toEqual({}); + }); + + it('a string already stored under one json key does not block a save that does not touch it', async () => { + const { svc, save, seed } = await boot(); + seed({ id: 'legacy', namespace: 'lifecycle', key: 'quota_defaults', value: '{"telemetry": 500}' }); + + const st = await save({ growth_alert_rows: 10 }); + + expect(st.status).toBe(200); + expect((await svc.get('lifecycle', 'growth_alert_rows')).value).toBe(10); + }); +}); + +describe('[#31241] the env door judges a `json` key the same way', () => { + const envService = (value: string) => { + const errors: string[] = []; + const svc = new SettingsService({ + env: { OS_LIFECYCLE_QUOTAS: value }, + logger: { error: (m: string) => void errors.push(m), warn: () => {} } as any, + }); + svc.registerManifest(lifecycleSettingsManifest); + return { svc, errors }; + }; + + it('CONTROL: JSON text of an object is in force, as the object', async () => { + const { svc, errors } = envService('{"sys_audit_log": 1000}'); + + const got = await svc.get('lifecycle', 'quotas'); + + expect(got.value).toEqual({ sys_audit_log: 1000 }); + expect(got.source).toBe('env'); + expect(errors).toEqual([]); + }); + + it('text that does not parse is not in force: the key resolves from the next layer, with one error line', async () => { + const { svc, errors } = envService('{"sys_audit_log": 1000'); + + const got = await svc.get('lifecycle', 'quotas'); + await svc.get('lifecycle', 'quotas'); + + expect(got.value).toEqual({}); + expect(got.source).not.toBe('env'); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('OS_LIFECYCLE_QUOTAS'); + expect(errors[0]).toContain("is not a JSON object or array, which its type 'json' declares"); + expect(errors[0]).toContain('a string of 22 characters'); + expect(errors[0]).not.toContain('sys_audit_log'); + }); + + it('text that parses to a scalar is not in force either', async () => { + const { svc, errors } = envService('5'); + + const got = await svc.get('lifecycle', 'quotas'); + + expect(got.value).toEqual({}); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('a number'); + }); +}); diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index acca6a1db3a..3bab7f477ea 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -767,6 +767,51 @@ function firstFormatMiss(type: string, value: unknown): FormatMiss | null { }; } +/** A `json` value that is not a JSON object or array — see {@link jsonShapeMiss}. */ +interface JsonShapeMiss { + /** The value's shape — its kind and, for a string, its length — never its content. */ + shape: string; +} + +/** + * The shape verdict for a value of a `json` specifier (#31241): a + * {@link JsonShapeMiss} when the value is neither a plain JSON object nor an + * array, else `null`. + * + * ADR-0007's specifier table declares `json` | code editor | **object**: the + * value a `json` key persists is the structure itself. Its readers index it as + * one — `LifecycleService.loadGovernance` reads `lifecycle.quotas` as + * `Record` — so a JSON *text* stored in its place is not a + * spelling of the same value. It is a different value, and every reader of the + * key reads it as "nothing configured", silently. The console's `json` field + * used to send exactly that: the textarea's text, a string. + * + * ONE judgment for both doors, as {@link firstFormatMiss} is: the save door + * ({@link SettingsService.validatePatch}) refuses a miss with one + * `invalid_type` `FieldError` for the key and stores nothing; the env door + * ({@link SettingsService.effectiveEnvOverride}) rejects an `OS_*` override + * whose text does not parse to an object or array, so it is not in force and + * the key resolves from the next layer of the cascade. + * + * `null` / `undefined` pass: that is a clear, which falls back to the default. + * Every other kind misses, a blank string included — a `json` key has no + * "empty text" value to store. + */ +function jsonShapeMiss(type: string, value: unknown): JsonShapeMiss | null { + if (type !== 'json') return null; + if (value === null || typeof value === 'undefined') return null; + if (Array.isArray(value)) return null; + if (typeof value === 'object') { + const proto = Object.getPrototypeOf(value); + if (proto === Object.prototype || proto === null) return null; + return { shape: 'a non-plain object' }; + } + if (typeof value === 'string') { + return { shape: `a string of ${value.length} character${value.length === 1 ? '' : 's'}` }; + } + return { shape: `a ${typeof value}` }; +} + /** The declared `type` of one specifier key, or `''` when the manifest declares no such key. */ function declaredTypeOf(manifest: SettingsManifest, key: string): string { const spec = (manifest.specifiers ?? []).find((s) => s.key === key); @@ -1502,6 +1547,23 @@ export class SettingsService { return null; } + // A `json` key persists a JSON object or array (ADR-0007; #31241). The + // override's text was parsed against the key's object default + // (`coerceEnvValue`); text that did not parse, or parsed to a scalar, is + // not that value, and every reader would index it as nothing configured. + // Same helper as the save path ({@link jsonShapeMiss}); the line carries the + // value's shape, never its content. + const shapeMiss = jsonShapeMiss(declaredTypeOf(reg.manifest, key), value); + if (shapeMiss) { + this.reportRejectedEnvOverride(reg, namespace, key, envName, value, { + what: "is not a JSON object or array, which its type 'json' declares for", + detail: 'Allowed values: JSON text that parses to an object or an array.', + fix: 'JSON text of an object or an array', + shape: shapeMiss.shape, + }); + return null; + } + return { envName, value }; } @@ -3429,6 +3491,24 @@ export class SettingsService { continue; } } + + // A `json` key persists a JSON object or array (ADR-0007; #31241), so a + // string — the JSON *text* the console's field used to send — or any + // other scalar is refused here rather than stored for every reader to + // index as nothing. Not gated on `empty`: a blank string is still a + // string where a structure is declared; only `null` (a clear) passes, + // inside the helper. The message names what to send and never the value. + const shapeMiss = jsonShapeMiss(type, value); + if (shapeMiss) { + errors.push({ + field: key, + code: 'invalid_type', + message: `${label} must be a JSON object or array, not ${shapeMiss.shape}. Send the parsed value, not its JSON text.`, + label, + constraint: { type: 'json' }, + }); + continue; + } } if (errors.length > 0) { diff --git a/ui/apps/console/src/pages/settings/SettingsView.tsx b/ui/apps/console/src/pages/settings/SettingsView.tsx index 680d3607f23..73ecf09eee8 100644 --- a/ui/apps/console/src/pages/settings/SettingsView.tsx +++ b/ui/apps/console/src/pages/settings/SettingsView.tsx @@ -218,8 +218,49 @@ function missesDeclaredFormat(type: string, value: unknown): boolean { return !re.test(value.trim()); } -/** Why the page blocked a save on one key: an empty `required`, or a format miss. */ -type PreSaveBlock = { key: string; rule: 'required' | 'format'; type: string }; +/** + * The value a `json` key's draft stands for (objectstack#31241). The field is a + * textarea, so its draft is the TEXT the admin typed; ADR-0007 declares a + * `json` key persists an object, and the settings service refuses a string for + * one. The text is parsed here, at save: blank text is a clear (`null`, back to + * the default), text that does not parse is `{ ok: false }` and blocks the + * save. A draft that is not a string (nothing typed yet) passes through. + */ +function parseJsonDraft(value: unknown): { ok: true; value: unknown } | { ok: false } { + if (typeof value !== 'string') return { ok: true, value }; + if (value.trim() === '') return { ok: true, value: null }; + try { + return { ok: true, value: JSON.parse(value) }; + } catch { + return { ok: false }; + } +} + +/** + * The patch a save sends: the draft, with every `json` key's text replaced by + * the value it parses to (objectstack#31241), so a `json` key never leaves the + * page as text. Called only after {@link preSaveBlocks} passed, which refuses + * text that does not parse; such a key would otherwise be sent unchanged and + * refused by the server. + */ +function outgoingSettingsPatch( + specifiers: ReadonlyArray, + draft: Record, +): Record { + const patch: Record = { ...draft }; + for (const spec of specifiers) { + if (spec.type !== 'json' || !spec.key || !(spec.key in draft)) continue; + const parsed = parseJsonDraft(draft[spec.key]); + if (parsed.ok) patch[spec.key] = parsed.value; + } + return patch; +} + +/** + * Why the page blocked a save on one key: an empty `required`, a format miss, + * or a `json` draft whose text does not parse (objectstack#31241). + */ +type PreSaveBlock = { key: string; rule: 'required' | 'format' | 'json'; type: string }; /** * The visible keys a save is blocked on, in manifest order: a `required` key @@ -250,7 +291,17 @@ function preSaveBlocks( if (!evalVisibility(spec.visible, liveValues)) continue; const resolved = values[key]; if (resolved?.locked) continue; - const effective = key in draft ? draft[key] : resolved?.value; + let effective = key in draft ? draft[key] : resolved?.value; + // A `json` key's draft is judged as the value its text parses to, and + // only the draft: a value already stored is not this save's to block. + if (spec.type === 'json' && key in draft) { + const parsed = parseJsonDraft(draft[key]); + if (!parsed.ok) { + blocks.push({ key, rule: 'json', type: spec.type }); + continue; + } + effective = parsed.value; + } if (spec.required && isEmptySettingValue(effective)) { blocks.push({ key, rule: 'required', type: spec.type }); } else if (missesDeclaredFormat(spec.type, effective)) { @@ -488,6 +539,7 @@ export function SettingsView() { if (blocked.length > 0) { const messageFor = ({ rule, type }: PreSaveBlock): string => { if (rule === 'required') return t('form.fieldRequired'); + if (rule === 'json') return t('fields.object.invalidJson'); if (type === 'email') return t('validation.email'); if (type === 'url') return t('validation.url'); if (type === 'phone') return t('console.settingsView.invalidPhone'); @@ -515,7 +567,7 @@ export function SettingsView() { // reflects THIS save's verdict rather than a stale one from a previous try. setCryptoRefusal(null); try { - const res = await saveSettingsNamespace(namespace, draft); + const res = await saveSettingsNamespace(namespace, outgoingSettingsPatch(manifest.specifiers, draft)); setPayload({ ...payload, values: { ...values, ...res.values } }); setDraft({}); setFieldErrors({}); diff --git a/ui/apps/console/src/pages/settings/__tests__/SettingsView.jsonSendsObject-31241.test.tsx b/ui/apps/console/src/pages/settings/__tests__/SettingsView.jsonSendsObject-31241.test.tsx new file mode 100644 index 00000000000..b6251a74f38 --- /dev/null +++ b/ui/apps/console/src/pages/settings/__tests__/SettingsView.jsonSendsObject-31241.test.tsx @@ -0,0 +1,144 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * SettingsView — a `json` setting leaves the page as the value its text parses + * to, never as the text (objectstack#31241). + * + * ADR-0007 declares `json` | code editor | object. The field is a textarea, and + * the page used to PUT the textarea's text: `lifecycle.quotas` was stored as a + * string, and the lifecycle sweep read every quota as absent. Now the text is + * parsed at Save: valid JSON is sent as the object or array, blank text as a + * clear (`null`), and text that does not parse blocks Save on that row the way + * a format miss does (objectui#13287), with the widget's own translated + * sentence (`fields.object.invalidJson`). + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +const getSettingsNamespace = vi.fn(); +const saveSettingsNamespace = vi.fn(); +const runSettingsAction = vi.fn(); + +vi.mock('../api', async () => { + const actual = await vi.importActual('../api'); + return { + ...actual, + getSettingsNamespace: (...a: unknown[]) => getSettingsNamespace(...a), + saveSettingsNamespace: (...a: unknown[]) => saveSettingsNamespace(...a), + runSettingsAction: (...a: unknown[]) => runSettingsAction(...a), + }; +}); + +const toastError = vi.fn(); +const toastSuccess = vi.fn(); +vi.mock('sonner', () => ({ + toast: { + error: (...a: unknown[]) => toastError(...a), + success: (...a: unknown[]) => toastSuccess(...a), + }, +})); + +import { SettingsView } from '../SettingsView'; + +/** The Data Lifecycle page's shape: a toggle, two `json` maps and a number. */ +function lifecyclePayload(values: Record = {}) { + const resolved: Record = {}; + const all = { enabled: true, quotas: {}, quota_defaults: {}, growth_alert_rows: 0, ...values }; + for (const [k, v] of Object.entries(all)) resolved[k] = { value: v, source: 'default', locked: false }; + return { + manifest: { + namespace: 'lifecycle', + label: 'Data Lifecycle', + specifiers: [ + { type: 'toggle', key: 'enabled', label: 'Enforce lifecycle policies' }, + { type: 'json', key: 'quotas', label: 'Row quotas' }, + { type: 'json', key: 'quota_defaults', label: 'Per-class quota defaults' }, + { type: 'number', key: 'growth_alert_rows', label: 'Growth alert threshold' }, + ], + }, + values: resolved, + }; +} + +function renderView() { + return render( + + + } /> + + , + ); +} + +const rowFor = (key: string) => document.querySelector(`[data-settings-key="${key}"]`); +const boxFor = (key: string) => rowFor(key)?.querySelector('textarea') ?? null; +const alertIn = (key: string) => rowFor(key)?.querySelector('[role="alert"]') ?? null; + +async function typeAndSave(key: string, text: string) { + await screen.findByText('Row quotas'); + fireEvent.change(boxFor(key)!, { target: { value: text } }); + fireEvent.click(await screen.findByRole('button', { name: /save/i })); +} + +beforeEach(() => { + vi.clearAllMocks(); + saveSettingsNamespace.mockResolvedValue({ values: {} }); +}); +afterEach(cleanup); + +describe('SettingsView — a `json` setting is sent as its parsed value (objectstack#31241)', () => { + it('sends the object the text parses to, never the text', async () => { + getSettingsNamespace.mockResolvedValue(lifecyclePayload()); + renderView(); + await typeAndSave('quotas', '{\n "sys_audit_log": 1000\n}'); + + await waitFor(() => expect(saveSettingsNamespace).toHaveBeenCalledTimes(1)); + const [namespace, patch] = saveSettingsNamespace.mock.calls[0] as [string, Record]; + expect(namespace).toBe('lifecycle'); + expect(patch).toEqual({ quotas: { sys_audit_log: 1000 } }); + expect(typeof patch.quotas).toBe('object'); + }); + + it('sends an array as an array, and blank text as a clear (null)', async () => { + getSettingsNamespace.mockResolvedValue(lifecyclePayload({ quota_defaults: { telemetry: 5 } })); + renderView(); + await screen.findByText('Row quotas'); + fireEvent.change(boxFor('quotas')!, { target: { value: '[1, 2]' } }); + fireEvent.change(boxFor('quota_defaults')!, { target: { value: ' ' } }); + fireEvent.click(await screen.findByRole('button', { name: /save/i })); + + await waitFor(() => expect(saveSettingsNamespace).toHaveBeenCalledTimes(1)); + expect(saveSettingsNamespace.mock.calls[0][1]).toEqual({ quotas: [1, 2], quota_defaults: null }); + }); + + it('text that does not parse blocks Save: no request, the row marked with the widget sentence', async () => { + getSettingsNamespace.mockResolvedValue(lifecyclePayload()); + renderView(); + await typeAndSave('quotas', '{ "sys_audit_log": 1000'); + + await waitFor(() => expect(alertIn('quotas')).toBeTruthy()); + // Provider-less, `t` answers the key itself: the widget's own key, not a new one. + expect(alertIn('quotas')!.textContent).toMatch(/invalidJson|Invalid JSON/); + expect(saveSettingsNamespace).not.toHaveBeenCalled(); + expect(toastSuccess).not.toHaveBeenCalled(); + }); + + it('a stored value that is not this save\'s does not block it', async () => { + // A deployment that saved through the old page holds the text; editing + // another key must still save. + getSettingsNamespace.mockResolvedValue(lifecyclePayload({ quota_defaults: '{ not json' })); + renderView(); + await typeAndSave('quotas', '{"sys_job_run": 1}'); + + await waitFor(() => expect(saveSettingsNamespace).toHaveBeenCalledTimes(1)); + expect(saveSettingsNamespace.mock.calls[0][1]).toEqual({ quotas: { sys_job_run: 1 } }); + }); +}); From 54b9a60977ddbf5417b4c60cc234d47ca8e87e8b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 20:58:33 +0000 Subject: [PATCH 2/6] docs(config-resolution): a json key's env override must parse to an object or array Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dxcqiQvzKhr5odTPe --- content/docs/protocol/kernel/config-resolution.mdx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/content/docs/protocol/kernel/config-resolution.mdx b/content/docs/protocol/kernel/config-resolution.mdx index a20b7e62dca..440b772d56f 100644 --- a/content/docs/protocol/kernel/config-resolution.mdx +++ b/content/docs/protocol/kernel/config-resolution.mdx @@ -261,6 +261,11 @@ OS_ALLOWED_ORIGINS=["a","b","c"] # default is array/object → JSON.parse ``` If the raw value cannot be parsed for the expected type, the raw string is used. +The exception is a `json` specifier, which persists a JSON object or array +(ADR-0007): an override whose text does not parse to one is not in force, one +`error` line names the variable, and the key resolves from the next layer of the +cascade. The save door refuses the same values for a `json` key with an +`invalid_type` field error, so a client sends the parsed value, never its text. **What is stored beneath a pin is listed by the namespace read.** An override in force answers `source: 'env'`, `locked: true`, and `get()` / `getMany()` build From 748e6b270c81a76a6e09b700df3ab6cadd1c3086 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 21:08:45 +0000 Subject: [PATCH 3/6] fix(objectql): call the lifecycle error channel through its logger Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dxcqiQvzKhr5odTPe --- .../src/lifecycle/lifecycle-service.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/objectql/src/lifecycle/lifecycle-service.ts b/packages/objectql/src/lifecycle/lifecycle-service.ts index 9cc08b54a02..a72afb26370 100644 --- a/packages/objectql/src/lifecycle/lifecycle-service.ts +++ b/packages/objectql/src/lifecycle/lifecycle-service.ts @@ -1192,13 +1192,18 @@ export class LifecycleService { } } if (first) { - const log = this.opts.logger.error ?? this.opts.logger.warn; - log.call( - this.opts.logger, - `[lifecycle] ${where}lifecycle.${key} is not an object (${typeof value === 'string' ? `a string of ${value.length} characters that is not a JSON object` : Array.isArray(value) ? 'an array' : `a ${typeof value}`}), ` + - 'so none of it applies: the declared lifecycle windows and no quotas are enforced instead. ' + - 'Set it again on the Data Lifecycle settings page.', - ); + const shape = + typeof value === 'string' + ? `a string of ${value.length} characters that is not a JSON object` + : Array.isArray(value) + ? 'an array' + : `a ${typeof value}`; + const message = + `[lifecycle] ${where}lifecycle.${key} is not an object (${shape}), ` + + 'so none of it applies: the declared lifecycle windows and no quotas are enforced instead. ' + + 'Set it again on the Data Lifecycle settings page.'; + if (this.opts.logger.error) this.opts.logger.error(message); + else this.opts.logger.warn(message); } return undefined; } From e8c24e00089de978cc22afc58acaf7d8de58cf68 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 22:05:24 +0000 Subject: [PATCH 4/6] fix(objectql): a retention override stored as JSON text waits for an administrator's save Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dxcqiQvzKhr5odTPe --- ...ecycle-service.governance-map-text.test.ts | 89 +++++++++++++++---- .../src/lifecycle/lifecycle-service.ts | 37 +++++++- 2 files changed, 107 insertions(+), 19 deletions(-) diff --git a/packages/objectql/src/lifecycle/lifecycle-service.governance-map-text.test.ts b/packages/objectql/src/lifecycle/lifecycle-service.governance-map-text.test.ts index 0df5550580c..213f869f6ec 100644 --- a/packages/objectql/src/lifecycle/lifecycle-service.governance-map-text.test.ts +++ b/packages/objectql/src/lifecycle/lifecycle-service.governance-map-text.test.ts @@ -10,10 +10,17 @@ * that saved the Data Lifecycle page holds a string where the map belongs. * `loadGovernance` indexed that string, so every override and quota read as * absent, with nothing logged. The save door now refuses a string - * (`service-settings`), but rows already stored are not re-judged: this reader - * parses a string that holds a JSON object, applies it and warns once per - * stored value; anything else that is not a plain object is ignored as before, - * now said once at `error`. + * (`service-settings`), but rows already stored are not re-judged. This reader + * splits them by what the key drives: + * + * - `quotas` / `quota_defaults` only alert: text that holds a JSON object is + * applied, with one warning per stored value. + * - `retention_overrides` drives deletion: its text is NOT applied (the + * declared windows stay in force, no row is reaped on the old window) and + * one error per stored value says how to apply it — save the page once. + * + * Anything else that is not a plain object is ignored as before, now said once + * at `error`. */ import { describe, it, expect, vi } from 'vitest'; @@ -41,15 +48,27 @@ function settingsOf(global: Record, tenant: Record = []) { const deletes: Array<{ where: unknown }> = []; const reads: Array<{ object: string; where: unknown }> = []; + /** Ids of `stored` rows a bulk delete's `created_at < cutoff` predicate removed. */ + const reaped: string[] = []; const driver = { name: 'default', count: vi.fn(async () => rows) }; const engine: any = { registry: { getAllObjects: () => [JOB_RUN] }, async delete(_object: string, options: any) { deletes.push({ where: options?.where }); - return { deletedCount: 1 }; + const cutoff = options?.where?.created_at?.$lt as string | undefined; + let deletedCount = 0; + if (cutoff) { + for (const row of stored) { + if (row.created_at < cutoff && !reaped.includes(row.id)) { + reaped.push(row.id); + deletedCount += 1; + } + } + } + return { deletedCount }; }, getDriverForObject: () => driver, datasource() { @@ -64,7 +83,7 @@ function engineOf(rows = 1_500, organizations: string[] = []) { return [{ id: typeof org === 'string' ? `${org}_row` : 'global_row' }]; }; } - return { engine, deletes, reads }; + return { engine, deletes, reads, reaped }; } function serviceOf(engine: unknown, settings: unknown) { @@ -118,31 +137,69 @@ describe('[#31241] a map setting stored as JSON text', () => { expect(report.alerts).toEqual([{ type: 'quota-exceeded', object: 'sys_job_run', rowCount: 1_500, quota: 500 }]); }); - it('a global retention override stored as text moves the window', async () => { - const { engine, deletes } = engineOf(); - const { svc, logger } = serviceOf(engine, settingsOf({ retention_overrides: '{"sys_job_run": {"maxAge": "90d"}}' })); + /** A row 10 days old: inside the declared 30d window, past a 1d override. */ + const TEN_DAYS_OLD = [{ id: 'row_10d', created_at: new Date(FIXED_NOW - 10 * 86_400_000).toISOString() }]; + + it('CONTROL: a retention override stored as an object reaps on its window', async () => { + const { engine, deletes, reaped } = engineOf(1_500, [], TEN_DAYS_OLD); + const { svc, logger } = serviceOf(engine, settingsOf({ retention_overrides: { sys_job_run: { maxAge: '1d' } } })); + + await svc.sweep(); + + expect(deletes).toEqual([{ where: { created_at: { $lt: isoCutoff('1d') } } }]); + expect(reaped).toEqual(['row_10d']); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('a retention override stored as text is NOT applied: no row is reaped on it, and one error says how to apply it', async () => { + const { engine, deletes, reaped } = engineOf(1_500, [], TEN_DAYS_OLD); + const { svc, logger } = serviceOf(engine, settingsOf({ retention_overrides: '{"sys_job_run": {"maxAge": "1d"}}' })); + await svc.sweep(); await svc.sweep(); - expect(deletes).toEqual([{ where: { created_at: { $lt: isoCutoff('90d') } } }]); - expect(logger.warn.mock.calls[0][0]).toContain('lifecycle.retention_overrides is stored as JSON text'); + // The declared 30d window, not the old 1d text: the 10-day-old row stays. + expect(deletes.map((d) => d.where)).toEqual([ + { created_at: { $lt: isoCutoff('30d') } }, + { created_at: { $lt: isoCutoff('30d') } }, + ]); + expect(reaped).toEqual([]); + expect(logger.error).toHaveBeenCalledTimes(1); + const line = logger.error.mock.calls[0][0] as string; + expect(line).toContain('lifecycle.retention_overrides is stored as JSON text, not an object'); + expect(line).toContain('so it is NOT applied'); + expect(line).toContain('Setup → Data Lifecycle'); + expect(line).not.toContain('sys_job_run'); + expect(logger.warn).not.toHaveBeenCalled(); }); - it("an organization's retention override stored as text runs that organization's pass on it", async () => { + it("an organization's retention override stored as text runs no pass of its own, and is named in the error", async () => { const { engine, reads } = engineOf(1_500, ['org_reg', 'org_plain']); const { svc, logger } = serviceOf( engine, - settingsOf({}, { org_reg: { retention_overrides: '{"sys_job_run": {"maxAge": "2y"}}' } }), + settingsOf({}, { org_reg: { retention_overrides: '{"sys_job_run": {"maxAge": "1d"}}' } }), ); await svc.sweep(); + // One global pass on the declared window; no organization is carved out. + const passes = reads.filter((r) => r.object === 'sys_job_run').map((r) => r.where); + expect(passes).toEqual([{ created_at: { $lt: isoCutoff('30d') } }]); + expect(logger.error.mock.calls[0][0]).toContain("organization org_reg's lifecycle.retention_overrides"); + expect(logger.error.mock.calls[0][0]).toContain('so it is NOT applied'); + }); + + it("CONTROL: the same organization's override stored as an object runs that organization's pass", async () => { + const { engine, reads } = engineOf(1_500, ['org_reg', 'org_plain']); + const { svc } = serviceOf(engine, settingsOf({}, { org_reg: { retention_overrides: { sys_job_run: { maxAge: '1d' } } } })); + + await svc.sweep(); + const passes = reads.filter((r) => r.object === 'sys_job_run').map((r) => r.where); expect(passes).toEqual([ - { created_at: { $lt: isoCutoff('2y') }, organization_id: 'org_reg' }, + { created_at: { $lt: isoCutoff('1d') }, organization_id: 'org_reg' }, { created_at: { $lt: isoCutoff('30d') }, organization_id: { $nin: ['org_reg'] } }, ]); - expect(logger.warn.mock.calls[0][0]).toContain("organization org_reg's lifecycle.retention_overrides"); }); it('text that is not a JSON object applies nothing, and says so once at error', async () => { diff --git a/packages/objectql/src/lifecycle/lifecycle-service.ts b/packages/objectql/src/lifecycle/lifecycle-service.ts index a72afb26370..ba3cd48f2de 100644 --- a/packages/objectql/src/lifecycle/lifecycle-service.ts +++ b/packages/objectql/src/lifecycle/lifecycle-service.ts @@ -282,6 +282,14 @@ const DEFAULT_GOVERNANCE: GovernanceSnapshot = { */ const GOVERNANCE_MAP_KEYS: ReadonlySet = new Set(['retention_overrides', 'quotas', 'quota_defaults']); +/** + * [#31241] Map settings whose value stored as JSON text is NOT applied by + * {@link LifecycleService.governanceMap}: they drive deletion (the reaper and + * the archiver), so an old value waits for an administrator's save instead of + * taking effect on the first sweep after an upgrade. + */ +const LEGACY_TEXT_NOT_APPLIED_KEYS: ReadonlySet = new Set(['retention_overrides']); + /** A plain object (not an array, not a class instance): the shape a map setting persists. */ function isPlainRecord(value: unknown): value is Record { if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; @@ -1157,9 +1165,20 @@ export class LifecycleService { * string where the map belongs; indexing that string reads every override and * quota as absent, with nothing logged. The save door refuses a string now, * but rows already stored are not re-judged, so this reader repairs them in - * place rather than through a migration: a string that parses to a plain - * object IS the map its author typed, and it is applied, with one `warn` per - * stored value naming the key and the one-step fix (save the page again). + * place rather than through a migration, split by what the key drives: + * + * - `quotas` / `quota_defaults` only ALERT. A string that parses to a plain + * object IS the map its author typed, and it is applied, with one `warn` + * per stored value naming the key and the one-step fix (save the page + * again). The worst an old value does is raise an alert. + * - `retention_overrides` drives the reaper and the archiver, which DELETE. + * A window nobody has seen enforced, possibly for years, must not start + * deleting rows on the first sweep after an upgrade, so its text is NOT + * applied: the declared windows stay in force, and one `error` per stored + * value names the key and the fix. Saving the Data Lifecycle page is that + * fix: the page shows the stored text and sends it as an object, so the + * administrator's save is the explicit consent the old value never had + * ({@link LEGACY_TEXT_NOT_APPLIED_KEYS}). * * Anything else that is not a plain object (text that does not parse, a * scalar, an array) cannot be applied. That was already the outcome; what @@ -1180,6 +1199,18 @@ export class LifecycleService { } catch { parsed = undefined; } + if (isPlainRecord(parsed) && LEGACY_TEXT_NOT_APPLIED_KEYS.has(key)) { + if (first) { + const message = + `[lifecycle] ${where}lifecycle.${key} is stored as JSON text, not an object ` + + '(saved by a console that sent the text), so it is NOT applied: the declared retention windows ' + + 'stay in force, and no row is reaped or archived on its windows. To apply it, open ' + + 'Setup → Data Lifecycle, check the value and save the page once; it is then stored as an object.'; + if (this.opts.logger.error) this.opts.logger.error(message); + else this.opts.logger.warn(message); + } + return undefined; + } if (isPlainRecord(parsed)) { if (first) { this.opts.logger.warn( From c4ad13974762e3b1622c0d40a1b623e9449c8709 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 22:05:44 +0000 Subject: [PATCH 5/6] docs(changeset): retention-override text waits for a save; quota text applies Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dxcqiQvzKhr5odTPe --- .changeset/31241-json-setting-persists-object.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.changeset/31241-json-setting-persists-object.md b/.changeset/31241-json-setting-persists-object.md index a9fb36d42c6..e06d03afe4f 100644 --- a/.changeset/31241-json-setting-persists-object.md +++ b/.changeset/31241-json-setting-persists-object.md @@ -9,7 +9,7 @@ fix(service-settings)!: a `json` setting persists a JSON object or array, and th Clause-②: yes (narrowing) — the settings save door and the env door start refusing a `json` value that is not a JSON object or array, which they accept today; ADR-0007:123 already declares `json` persists an object, so the narrowing enforces the declared contract and widens nothing Metadata-protocol: none - + **BREAKING** accept-set narrowing at the settings doors (`@objectstack/service-settings`): a `json` key takes a JSON object or array, as ADR-0007 declares. @@ -19,4 +19,9 @@ Metadata-protocol: none **The remedy.** Send the parsed value, not its text: `{ "quotas": { "sys_audit_log": 1000 } }`, not `{ "quotas": "{\"sys_audit_log\": 1000}" }`. For an `OS_*` override, set JSON text of an object, such as `OS_LIFECYCLE_QUOTAS='{"sys_audit_log":1000}'`. -**Values saved as text before this release** (`@objectstack/objectql`). A deployment that saved the Data Lifecycle page through the old console holds JSON text. The lifecycle service now reads text that holds a JSON object as that object and applies it, and logs one warning per stored value naming the key: `lifecycle.quotas is stored as JSON text, not an object … Save the Data Lifecycle settings page again to store it as an object.` So quotas and retention overrides saved earlier start being enforced on the first sweep after the upgrade, with no migration. Check them before you upgrade: a retention override that never applied before will now delete or keep rows on its window. A stored value that is not a JSON object still applies nothing, as before, and is now reported once at `error`. +**Values saved as text before this release** (`@objectstack/objectql`). A deployment that saved the Data Lifecycle page through the old console holds JSON text, which applied nothing. After the upgrade the lifecycle service treats the three keys by what they drive: + +- `quotas` and `quota_defaults` only raise alerts, so text that holds a JSON object is read as that object and applied, with one warning per stored value: `lifecycle.quotas is stored as JSON text, not an object … Save the Data Lifecycle settings page again to store it as an object.` +- `retention_overrides` drives the reaper and the archiver, which delete rows, so its text is **not** applied: the declared retention windows stay in force and no row is reaped or archived on the old windows. One error per stored value names the key (and the organization, for an organization's own value) and the fix: open Setup → Data Lifecycle, check the value and save the page once. The page shows the stored text and now sends it as an object, so that save is what puts the window in force. + +A stored value that is not a JSON object still applies nothing, as before, and is now reported once at `error`. From 29159a9b4f44a794f5259469c804da1c2ee94983 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 22:44:34 +0000 Subject: [PATCH 6/6] fix(console): offer a json setting stored as text for one save A json key whose stored value is text (saved by the console before this fix) and is not locked is put in the draft at load, so the page opens with the save bar and a note on the row; Save sends the parsed object. Without it the "save the page once" remedy found no Save button. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dxcqiQvzKhr5odTPe --- .../31241-json-setting-persists-object.md | 4 +- .../src/pages/settings/SettingsView.tsx | 35 ++++++++++- ...ettingsView.jsonSendsObject-31241.test.tsx | 59 +++++++++++++++++-- ui/packages/i18n/src/locales/ar.ts | 1 + ui/packages/i18n/src/locales/de.ts | 1 + ui/packages/i18n/src/locales/en.ts | 3 + ui/packages/i18n/src/locales/es.ts | 1 + ui/packages/i18n/src/locales/fr.ts | 1 + ui/packages/i18n/src/locales/ja.ts | 1 + ui/packages/i18n/src/locales/ko.ts | 1 + ui/packages/i18n/src/locales/pt-BR.ts | 1 + ui/packages/i18n/src/locales/ru.ts | 1 + ui/packages/i18n/src/locales/zh-Hans.ts | 1 + ui/packages/i18n/src/locales/zh-Hant.ts | 1 + 14 files changed, 103 insertions(+), 8 deletions(-) diff --git a/.changeset/31241-json-setting-persists-object.md b/.changeset/31241-json-setting-persists-object.md index e06d03afe4f..ae151187be3 100644 --- a/.changeset/31241-json-setting-persists-object.md +++ b/.changeset/31241-json-setting-persists-object.md @@ -15,13 +15,13 @@ Metadata-protocol: none - **Before.** `PUT /api/settings/:namespace` stored any value under a `json` key. The console's `json` field sent the textarea's text, so Setup → Data Lifecycle saved `lifecycle.quotas`, `lifecycle.quota_defaults` and `lifecycle.retention_overrides` as strings, answered "saved", and the lifecycle sweep read every quota and override as absent: no `quota-exceeded` alert, no override applied, nothing logged. - **Now.** The save door refuses a string, number or boolean under a `json` key with `400 SETTINGS_VALIDATION` and one `invalid_type` field error for the key, and stores nothing. An object, an array and `null` (a clear) are accepted. A value already stored under a key the save does not touch does not block it. An `OS_*` override for a `json` key whose text does not parse to an object or array is not in force: one `error` line names the variable, and the key resolves from the next layer of the cascade. -- **Who moves.** The console now parses the text at Save: valid JSON is sent as the object or array, blank text as a clear, and text that does not parse blocks Save on that row with "Invalid JSON". A script that PUTs JSON text for a `json` key gets the 400. +- **Who moves.** The console now parses the text at Save: valid JSON is sent as the object or array, blank text as a clear, and text that does not parse blocks Save on that row with "Invalid JSON". A `json` value the page loads as text (and is not locked) is put in the draft as it stands, so the page opens with one unsaved change per such key and a note on its row saying the value is stored as text; Save sends the object. A script that PUTs JSON text for a `json` key gets the 400. **The remedy.** Send the parsed value, not its text: `{ "quotas": { "sys_audit_log": 1000 } }`, not `{ "quotas": "{\"sys_audit_log\": 1000}" }`. For an `OS_*` override, set JSON text of an object, such as `OS_LIFECYCLE_QUOTAS='{"sys_audit_log":1000}'`. **Values saved as text before this release** (`@objectstack/objectql`). A deployment that saved the Data Lifecycle page through the old console holds JSON text, which applied nothing. After the upgrade the lifecycle service treats the three keys by what they drive: - `quotas` and `quota_defaults` only raise alerts, so text that holds a JSON object is read as that object and applied, with one warning per stored value: `lifecycle.quotas is stored as JSON text, not an object … Save the Data Lifecycle settings page again to store it as an object.` -- `retention_overrides` drives the reaper and the archiver, which delete rows, so its text is **not** applied: the declared retention windows stay in force and no row is reaped or archived on the old windows. One error per stored value names the key (and the organization, for an organization's own value) and the fix: open Setup → Data Lifecycle, check the value and save the page once. The page shows the stored text and now sends it as an object, so that save is what puts the window in force. +- `retention_overrides` drives the reaper and the archiver, which delete rows, so its text is **not** applied: the declared retention windows stay in force and no row is reaped or archived on the old windows. One error per stored value names the key (and the organization, for an organization's own value) and the fix: open Setup → Data Lifecycle, check the value and save the page once. The page opens with the stored text already marked as an unsaved change and sends it as an object, so that one Save, with no edit, is what puts the window in force. A stored value that is not a JSON object still applies nothing, as before, and is now reported once at `error`. diff --git a/ui/apps/console/src/pages/settings/SettingsView.tsx b/ui/apps/console/src/pages/settings/SettingsView.tsx index 73ecf09eee8..443171a579a 100644 --- a/ui/apps/console/src/pages/settings/SettingsView.tsx +++ b/ui/apps/console/src/pages/settings/SettingsView.tsx @@ -256,6 +256,35 @@ function outgoingSettingsPatch( return patch; } +/** + * True when a `json` key's STORED value is text (objectstack#31241): a console + * before that fix saved the textarea's text, so the setting holds a string + * where ADR-0007 declares an object. A locked row is left alone: nothing on + * this page can change it. + */ +function isStoredJsonText(spec: Specifier, resolved: { value?: unknown; locked?: boolean } | undefined): boolean { + return spec.type === 'json' && !!resolved && !resolved.locked && typeof resolved.value === 'string'; +} + +/** + * The draft a freshly loaded namespace starts with (objectstack#31241): every + * `json` key whose stored value is text, with that text. The page only saves + * what is in the draft, so without this an administrator told to "save the + * page once" would find no Save at all and the text would stay text. Seeded, + * the row counts as one unsaved change, and Save sends it through + * {@link outgoingSettingsPatch} as the object it parses to. Text that does not + * parse is blocked on its row like any other draft. + */ +function storedJsonTextDraft(payload: SettingsNamespacePayload): Record { + const draft: Record = {}; + for (const spec of payload.manifest.specifiers) { + if (!spec.key) continue; + const resolved = payload.values[spec.key]; + if (isStoredJsonText(spec, resolved)) draft[spec.key] = resolved!.value; + } + return draft; +} + /** * Why the page blocked a save on one key: an empty `required`, a format miss, * or a `json` draft whose text does not parse (objectstack#31241). @@ -399,7 +428,7 @@ export function SettingsView() { try { const p = await getSettingsNamespace(namespace); setPayload(p); - setDraft({}); + setDraft(storedJsonTextDraft(p)); setFieldErrors({}); setCryptoRefusal(null); } catch (err: any) { @@ -806,7 +835,9 @@ export function SettingsView() { namespace === CLIENT_ADDRESS_NAMESPACE && key === CLIENT_ADDRESS_KEY ? clientAddressNote(judgedAddress, t) - : undefined + : isStoredJsonText(spec, resolved) + ? t('console.settingsView.jsonStoredAsText') + : undefined } // The scope the server writes this key at: the specifier's // own, else the manifest's, else the spec's default `tenant`. diff --git a/ui/apps/console/src/pages/settings/__tests__/SettingsView.jsonSendsObject-31241.test.tsx b/ui/apps/console/src/pages/settings/__tests__/SettingsView.jsonSendsObject-31241.test.tsx index b6251a74f38..d7fee135415 100644 --- a/ui/apps/console/src/pages/settings/__tests__/SettingsView.jsonSendsObject-31241.test.tsx +++ b/ui/apps/console/src/pages/settings/__tests__/SettingsView.jsonSendsObject-31241.test.tsx @@ -131,14 +131,65 @@ describe('SettingsView — a `json` setting is sent as its parsed value (objects expect(toastSuccess).not.toHaveBeenCalled(); }); - it('a stored value that is not this save\'s does not block it', async () => { - // A deployment that saved through the old page holds the text; editing - // another key must still save. - getSettingsNamespace.mockResolvedValue(lifecyclePayload({ quota_defaults: '{ not json' })); + it('a locked stored value is not seeded and does not block an unrelated save', async () => { + // An env override pins the text; nothing on this page can change it, so it + // is neither put in the draft nor judged. + const payload = lifecyclePayload(); + payload.values.quota_defaults = { value: '{ not json', source: 'env', locked: true }; + getSettingsNamespace.mockResolvedValue(payload); renderView(); + await screen.findByText('Row quotas'); + expect(screen.queryByTestId('settings-unsaved-count')).toBeNull(); await typeAndSave('quotas', '{"sys_job_run": 1}'); await waitFor(() => expect(saveSettingsNamespace).toHaveBeenCalledTimes(1)); expect(saveSettingsNamespace.mock.calls[0][1]).toEqual({ quotas: { sys_job_run: 1 } }); }); }); + +describe('SettingsView — a `json` value stored as text is offered for one save (objectstack#31241)', () => { + const STORED = '{"sys_audit_log": 1000}'; + const noteIn = (key: string) => rowFor(key)?.querySelector('[data-testid="settings-field-note"]') ?? null; + + it('shows the save bar on load with the stored text as one unsaved change, and a note on the row', async () => { + getSettingsNamespace.mockResolvedValue(lifecyclePayload({ quotas: STORED })); + renderView(); + + const bar = await screen.findByTestId('settings-unsaved-count'); + // Provider-less, `t` answers the key; the count is the draft's size. + expect(bar.textContent).toMatch(/unsavedCount/); + expect(boxFor('quotas')!.value).toBe(STORED); + expect(noteIn('quotas')?.textContent).toMatch(/jsonStoredAsText/); + // A key stored as an object is neither seeded nor noted. + expect(noteIn('quota_defaults')).toBeNull(); + expect(saveSettingsNamespace).not.toHaveBeenCalled(); + }); + + it('clicking Save without touching the row PUTs the object the stored text holds', async () => { + // The load reads the text; the re-read after the save reads the object. + getSettingsNamespace + .mockResolvedValueOnce(lifecyclePayload({ quotas: STORED })) + .mockResolvedValue(lifecyclePayload({ quotas: { sys_audit_log: 1000 } })); + saveSettingsNamespace.mockResolvedValue({ + values: { quotas: { value: { sys_audit_log: 1000 }, source: 'global', locked: false } }, + }); + renderView(); + fireEvent.click(await screen.findByRole('button', { name: /save/i })); + + await waitFor(() => expect(saveSettingsNamespace).toHaveBeenCalledTimes(1)); + expect(saveSettingsNamespace.mock.calls[0]).toEqual(['lifecycle', { quotas: { sys_audit_log: 1000 } }]); + // Stored as an object now: the bar and the note are gone. + await waitFor(() => expect(screen.queryByTestId('settings-unsaved-count')).toBeNull()); + expect(noteIn('quotas')).toBeNull(); + }); + + it('stored text that does not parse is seeded too, and Save stops on its row', async () => { + getSettingsNamespace.mockResolvedValue(lifecyclePayload({ quota_defaults: '{ not json' })); + renderView(); + fireEvent.click(await screen.findByRole('button', { name: /save/i })); + + await waitFor(() => expect(alertIn('quota_defaults')).toBeTruthy()); + expect(alertIn('quota_defaults')!.textContent).toMatch(/invalidJson|Invalid JSON/); + expect(saveSettingsNamespace).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 8ed177a498c..1ed55c27a48 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -2455,6 +2455,7 @@ const ar = { unsupportedSpecifierType: "نوع إعداد غير مدعوم: {{type}}", invalidPhone: "أدخل رقم هاتف يتكون من الأرقام والمسافات و + ( ) - . فقط", invalidColor: "أدخل لونًا سداسيًا عشريًا، مثل #1a2b3c", + jsonStoredAsText: "هذه القيمة محفوظة كنص وليس ككائن JSON. احفظ لتخزينها ككائن؛ وقد علّمتها الصفحة كتغيير غير محفوظ.", clientAddressDirect: "عنوانك كما يحدده الخادم: {{address}}. لا يمكن حفظ قائمة لا تتضمنه من هنا. لا يثق هذا الخادم بترويسات الوكيل، لذا خلف وكيل يكون هذا عنوان الوكيل.", clientAddressViaProxy: "عنوانك كما يحدده الخادم: {{address}}، مقروءًا من ترويسات التمرير للوكيل الذي يثق به هذا الخادم. لا يمكن حفظ قائمة لا تتضمنه من هنا.", clientAddressUnknown: "لا يستطيع الخادم تحديد عنوانك، لذا لا يمكن حفظ قائمة غير فارغة من هنا.", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 6698720ba2a..00a67a14e94 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -2216,6 +2216,7 @@ const de = { unsupportedSpecifierType: "Nicht unterstützter Einstellungstyp: {{type}}", invalidPhone: "Geben Sie eine Telefonnummer nur mit Ziffern, Leerzeichen und + ( ) - . ein", invalidColor: "Geben Sie eine Hex-Farbe ein, z. B. #1a2b3c", + jsonStoredAsText: "Dieser Wert ist als Text gespeichert, nicht als JSON-Objekt. Speichern Sie, um ihn als Objekt zu speichern; die Seite hat ihn als ungespeicherte Änderung markiert.", clientAddressDirect: "Ihre Adresse, wie der Server sie beurteilt: {{address}}. Eine Liste ohne diese Adresse kann von hier aus nicht gespeichert werden. Dieser Server vertraut keinen Proxy-Headern, hinter einem Proxy ist dies also die Adresse des Proxys.", clientAddressViaProxy: "Ihre Adresse, wie der Server sie beurteilt: {{address}}, gelesen aus den Weiterleitungs-Headern des Proxys, dem dieser Server vertraut. Eine Liste ohne diese Adresse kann von hier aus nicht gespeichert werden.", clientAddressUnknown: "Der Server kann Ihre Adresse nicht ermitteln, daher kann von hier aus keine nicht leere Liste gespeichert werden.", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 602df912334..fc9afc058da 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -2378,6 +2378,9 @@ const en = { // `validation.url`; these two name what a phone number and a colour take. invalidPhone: 'Enter a phone number using only digits, spaces and + ( ) - .', invalidColor: 'Enter a hex color, such as #1a2b3c', + // A `json` setting stored as text by the console before objectstack#31241: + // the page puts that text in the draft so one Save stores it as an object. + jsonStoredAsText: 'This value is stored as text, not as a JSON object. Save to store it as an object; the page has marked it as an unsaved change.', // The address the server judges beside Allowed IP ranges (objectstack#30313), // one whole sentence per state of `GET /api/v1/auth/admin/client-address`: // `trustProxy` off, on, and no address the server can establish. diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 60828da210b..4930507b250 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -2278,6 +2278,7 @@ const es = { unsupportedSpecifierType: "Tipo de ajuste no admitido: {{type}}", invalidPhone: "Introduzca un número de teléfono solo con dígitos, espacios y + ( ) - .", invalidColor: "Introduzca un color hexadecimal, como #1a2b3c", + jsonStoredAsText: "Este valor está guardado como texto, no como objeto JSON. Guarde para almacenarlo como objeto; la página lo ha marcado como cambio sin guardar.", clientAddressDirect: "Su dirección, tal como la juzga el servidor: {{address}}. Una lista que no la incluya no se puede guardar desde aquí. Este servidor no confía en los encabezados de proxy, así que detrás de un proxy esta es la dirección del proxy.", clientAddressViaProxy: "Su dirección, tal como la juzga el servidor: {{address}}, leída de los encabezados reenviados por el proxy en el que confía este servidor. Una lista que no la incluya no se puede guardar desde aquí.", clientAddressUnknown: "El servidor no puede determinar su dirección, así que desde aquí no se puede guardar una lista que no esté vacía.", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 83cbf94afcd..13b604c7c3d 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -2274,6 +2274,7 @@ const fr = { unsupportedSpecifierType: "Type de paramètre non pris en charge : {{type}}", invalidPhone: "Saisissez un numéro de téléphone composé uniquement de chiffres, d’espaces et de + ( ) - .", invalidColor: "Saisissez une couleur hexadécimale, par exemple #1a2b3c", + jsonStoredAsText: "Cette valeur est enregistrée sous forme de texte, et non comme objet JSON. Enregistrez pour la stocker comme objet ; la page l'a marquée comme modification non enregistrée.", clientAddressDirect: "Votre adresse, telle que le serveur la juge : {{address}}. Une liste qui ne la contient pas ne peut pas être enregistrée d’ici. Ce serveur ne fait pas confiance aux en-têtes de proxy : derrière un proxy, c’est donc l’adresse du proxy.", clientAddressViaProxy: "Votre adresse, telle que le serveur la juge : {{address}}, lue dans les en-têtes transférés par le proxy auquel ce serveur fait confiance. Une liste qui ne la contient pas ne peut pas être enregistrée d’ici.", clientAddressUnknown: "Le serveur ne peut pas établir votre adresse : une liste non vide ne peut donc pas être enregistrée d’ici.", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 71494689c22..4035aa958c0 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -2218,6 +2218,7 @@ const ja = { unsupportedSpecifierType: "サポートされていない設定項目の種類: {{type}}", invalidPhone: "数字、スペース、+ ( ) - . のみで電話番号を入力してください", invalidColor: "#1a2b3c のような16進カラーを入力してください", + jsonStoredAsText: "この値は JSON オブジェクトではなくテキストとして保存されています。保存するとオブジェクトとして保存されます。ページでは未保存の変更として表示しています。", clientAddressDirect: "サーバーが判定したあなたのアドレス: {{address}}。このアドレスを含まないリストはここから保存できません。このサーバーはプロキシのヘッダーを信頼しないため、プロキシの背後ではプロキシのアドレスになります。", clientAddressViaProxy: "サーバーが判定したあなたのアドレス: {{address}}(このサーバーが信頼するプロキシの転送ヘッダーから取得)。このアドレスを含まないリストはここから保存できません。", clientAddressUnknown: "サーバーがあなたのアドレスを特定できないため、空でないリストはここから保存できません。", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 94d141feaa2..5a2273b0174 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -2216,6 +2216,7 @@ const ko = { unsupportedSpecifierType: "지원되지 않는 설정 항목 유형: {{type}}", invalidPhone: "숫자, 공백, + ( ) - . 만 사용하여 전화번호를 입력해주세요", invalidColor: "#1a2b3c 와 같은 16진수 색상을 입력해주세요", + jsonStoredAsText: "이 값은 JSON 객체가 아닌 텍스트로 저장되어 있습니다. 저장하면 객체로 저장됩니다. 페이지에서 저장하지 않은 변경 사항으로 표시했습니다.", clientAddressDirect: "서버가 판단한 사용자 주소: {{address}}. 이 주소가 포함되지 않은 목록은 여기에서 저장할 수 없습니다. 이 서버는 프록시 헤더를 신뢰하지 않으므로, 프록시 뒤에서는 프록시의 주소가 표시됩니다.", clientAddressViaProxy: "서버가 판단한 사용자 주소: {{address}} (이 서버가 신뢰하는 프록시의 전달 헤더에서 읽음). 이 주소가 포함되지 않은 목록은 여기에서 저장할 수 없습니다.", clientAddressUnknown: "서버가 사용자 주소를 확인할 수 없으므로, 비어 있지 않은 목록은 여기에서 저장할 수 없습니다.", diff --git a/ui/packages/i18n/src/locales/pt-BR.ts b/ui/packages/i18n/src/locales/pt-BR.ts index cb6dc97c463..a22b61941f3 100644 --- a/ui/packages/i18n/src/locales/pt-BR.ts +++ b/ui/packages/i18n/src/locales/pt-BR.ts @@ -2273,6 +2273,7 @@ const pt = { unsupportedSpecifierType: "Tipo de configuração não suportado: {{type}}", invalidPhone: "Insira um número de telefone usando apenas dígitos, espaços e + ( ) - .", invalidColor: "Insira uma cor hexadecimal, como #1a2b3c", + jsonStoredAsText: "Este valor está salvo como texto, não como objeto JSON. Salve para armazená-lo como objeto; a página o marcou como alteração não salva.", clientAddressDirect: "Seu endereço, como o servidor o avalia: {{address}}. Uma lista que não o inclua não pode ser salva daqui. Este servidor não confia em cabeçalhos de proxy, então, atrás de um proxy, este é o endereço do proxy.", clientAddressViaProxy: "Seu endereço, como o servidor o avalia: {{address}}, lido dos cabeçalhos encaminhados pelo proxy em que este servidor confia. Uma lista que não o inclua não pode ser salva daqui.", clientAddressUnknown: "O servidor não consegue determinar seu endereço, então uma lista não vazia não pode ser salva daqui.", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index e233970e853..b843e46418a 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -2342,6 +2342,7 @@ const ru = { unsupportedSpecifierType: "Неподдерживаемый тип настройки: {{type}}", invalidPhone: "Введите номер телефона, используя только цифры, пробелы и + ( ) - .", invalidColor: "Введите цвет в шестнадцатеричном формате, например #1a2b3c", + jsonStoredAsText: "Это значение сохранено как текст, а не как JSON-объект. Сохраните, чтобы записать его как объект; страница отметила его как несохранённое изменение.", clientAddressDirect: "Ваш адрес, как его определяет сервер: {{address}}. Список без этого адреса нельзя сохранить отсюда. Этот сервер не доверяет заголовкам прокси, поэтому за прокси это адрес самого прокси.", clientAddressViaProxy: "Ваш адрес, как его определяет сервер: {{address}}, взят из пересылаемых заголовков прокси, которому доверяет этот сервер. Список без этого адреса нельзя сохранить отсюда.", clientAddressUnknown: "Сервер не может определить ваш адрес, поэтому непустой список нельзя сохранить отсюда.", diff --git a/ui/packages/i18n/src/locales/zh-Hans.ts b/ui/packages/i18n/src/locales/zh-Hans.ts index 2287cd1faae..2eecb7571b2 100644 --- a/ui/packages/i18n/src/locales/zh-Hans.ts +++ b/ui/packages/i18n/src/locales/zh-Hans.ts @@ -1990,6 +1990,7 @@ const zh = { unsupportedSpecifierType: '不支持的设置项类型:{{type}}', invalidPhone: '请输入仅由数字、空格和 + ( ) - . 组成的电话号码', invalidColor: '请输入十六进制颜色,例如 #1a2b3c', + jsonStoredAsText: '该值以文本形式保存,而不是 JSON 对象。保存后将改存为对象;页面已将其标记为未保存的更改。', clientAddressDirect: '服务器判定的你的地址:{{address}}。不包含该地址的列表无法在此保存。此服务器不信任代理转发头,若部署在代理之后,这里显示的是代理的地址。', clientAddressViaProxy: '服务器判定的你的地址:{{address}},取自此服务器所信任代理的转发头。不包含该地址的列表无法在此保存。', clientAddressUnknown: '服务器无法确定你的地址,因此无法在此保存非空的列表。', diff --git a/ui/packages/i18n/src/locales/zh-Hant.ts b/ui/packages/i18n/src/locales/zh-Hant.ts index b2f5332d5fd..c761edd0edc 100644 --- a/ui/packages/i18n/src/locales/zh-Hant.ts +++ b/ui/packages/i18n/src/locales/zh-Hant.ts @@ -1990,6 +1990,7 @@ const zhHant = { unsupportedSpecifierType: '不支援的設定項型別:{{type}}', invalidPhone: '請輸入僅由數字、空格和 + ( ) - . 組成的電話號碼', invalidColor: '請輸入十六進位制顏色,例如 #1a2b3c', + jsonStoredAsText: '該值以文本形式儲存,而不是 JSON 物件。儲存後將改存為物件;頁面已將其標記為未儲存的更改。', clientAddressDirect: '伺服器判定的你的地址:{{address}}。不包含該地址的列表無法在此儲存。此伺服器不信任代理轉發頭,若部署在代理之後,這裡顯示的是代理的地址。', clientAddressViaProxy: '伺服器判定的你的地址:{{address}},取自此伺服器所信任代理的轉發頭。不包含該地址的列表無法在此儲存。', clientAddressUnknown: '伺服器無法確定你的地址,因此無法在此儲存非空的列表。',