Skip to content

Commit cde1975

Browse files
authored
fix(dev): eliminate three fixed startup log warnings so official examples boot clean (#3420) (#3449)
Clears the three fixed startup-log noise sources on the stock showcase so an official example boots warning-free, adds regression guards to keep it clean, and makes the registry debug output discoverable via OS_REGISTRY_LOG. - spec: field-level ackPlaintextMasking opt-out for the ADR-0100 generic-password author warning - plugin-auth: silence better-auth's false-positive oauthAuthServerConfig well-known reminder (we mount those docs ourselves) - objectql: route registry re-register / package-overwrite lines to a debug-only logger; add logLevel / OS_REGISTRY_LOG - regression guards across spec, objectql, plugin-auth, and the showcase/crm examples Closes #3420.
1 parent 0c302a7 commit cde1975

14 files changed

Lines changed: 300 additions & 7 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/objectql": patch
4+
"@objectstack/plugin-auth": patch
5+
---
6+
7+
fix(dev): eliminate three fixed startup log warnings so official examples boot clean (#3420)
8+
9+
`os dev` on the stock showcase printed three fixed noise sources on every boot,
10+
with zero example-side changes — training users to ignore warnings.
11+
12+
- **spec** — add a field-level `ackPlaintextMasking: true` opt-out for the
13+
generic `password` author-time warning (ADR-0100). A deliberately-masked
14+
field (like field-zoo's `f_password`) can now affirm intent instead of
15+
printing an un-actionable "safe to ignore" on every boot; the warning text
16+
points authors at the flag.
17+
- **plugin-auth** — pass better-auth's documented
18+
`silenceWarnings.oauthAuthServerConfig` to `oauthProvider(...)`. We already
19+
mount the `/.well-known/oauth-authorization-server` documents ourselves at
20+
the issuer root, so the plugin's "please ensure it exists" reminder was a
21+
false positive (printed twice); silencing it removes both.
22+
- **objectql** — route the Registry's re-register / package-overwrite lines
23+
(normal rebuild / HMR / seed-replay paths) through a new debug-only
24+
`SchemaRegistry.debug()` so they stay out of the default `info` boot log. Adds
25+
a `logLevel` construction option (and matching `OS_REGISTRY_LOG` env var) so
26+
the debug-gated housekeeping is discoverable for troubleshooting.

content/docs/references/data/field.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ const result = Address.parse(data);
147147
| **hidden** | `boolean` | optional | Hidden from default UI |
148148
| **readonly** | `boolean` | optional | Read-only — never editable in forms, AND server-enforced on BOTH write paths: a non-system write to this field is silently dropped from the payload on UPDATE (#2948/#3003) and on INSERT (#3043; a create can no longer directly seed e.g. `approval_status: "approved"`), symmetric with `readonlyWhen`. A stripped INSERT field still falls back to its `defaultValue`; system-context writes (import, seed replay, migration) are exempt. |
149149
| **requiredPermissions** | `string[]` | optional | [ADR-0066 D3] Capabilities required to read/edit this field (mask on read, deny on write; AND-gate). |
150+
| **ackPlaintextMasking** | `boolean` | optional | [ADR-0100] Affirm a generic `password` field's plaintext-at-rest / masked-on-read contract is intended, silencing the author-time warning (#3420). No effect on non-password fields. |
150151
| **system** | `boolean` | optional | Auto-injected system/audit field (e.g. created_at, updated_by, organization_id). Tools that surface system fields separately from author-declared business fields should branch on this flag. |
151152
| **sortable** | `boolean` | optional | Whether field is sortable in list views |
152153
| **inlineHelpText** | `string` | optional | Help text displayed below the field in forms |

docs/adr/0100-credential-field-channels.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,15 @@ rest but masked on read**:
9595
would be self-inflicted breakage. Raw `.parse()` stays silent, since it also
9696
loads persisted metadata and `create()` is the authoring surface (ADR-0077).
9797

98+
**Opt-out — `ackPlaintextMasking: true` (#3420).** A deliberate `password`
99+
field (like field-zoo's `f_password`) can affirm intent with a field-level
100+
`ackPlaintextMasking: true`; the warning then skips that field. The original
101+
text said the warning was "safe to ignore" but offered no way to *express*
102+
that intent, so the official showcase booted with an unavoidable warning —
103+
training users to ignore warnings. The flag is the documented affirmation and
104+
lets the stock example start warning-free. It is diagnostic-only: masking,
105+
the echoed-mask guard, and the better-auth exemption are all unchanged by it.
106+
98107
### C. Shared mechanism
99108

100109
Both channels share one read-mask collector — `collectMaskedReadFields`

examples/app-crm/test/smoke.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,23 @@ describe('app-crm minimal metadata bundle', () => {
9595
expect(rules.some((r) => r.type === 'owner')).toBe(true);
9696
});
9797

98+
// #3420 — official examples must boot warning-free. A generic (non-better-auth)
99+
// `password` field trips the ADR-0100 author-time warning unless it affirms
100+
// intent with `ackPlaintextMasking: true`. crm ships none today; this guard
101+
// fails loudly if one is ever added without the acknowledgment.
102+
it('has no un-acknowledged generic password fields (#3420)', () => {
103+
const offenders: string[] = [];
104+
for (const obj of (stack.objects ?? []) as any[]) {
105+
if (obj?.managedBy === 'better-auth') continue;
106+
for (const [fieldName, def] of Object.entries((obj?.fields ?? {}) as Record<string, any>)) {
107+
if (def?.type === 'password' && def?.ackPlaintextMasking !== true) {
108+
offenders.push(`${obj.name}.${fieldName}`);
109+
}
110+
}
111+
}
112+
expect(offenders, `un-acknowledged generic password field(s): ${offenders.join(', ')}`).toEqual([]);
113+
});
114+
98115
});
99116

100117
describe('Pipeline dashboard', () => {

examples/app-showcase/src/data/objects/field-zoo.object.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@ export const FieldZoo = ObjectSchema.create({
3535
f_email: Field.email({ label: 'Email', searchable: true }),
3636
f_url: Field.url({ label: 'URL' }),
3737
f_phone: Field.phone({ label: 'Phone' }),
38-
f_password: Field.password({ label: 'Password (masked on read)' }),
38+
// field-zoo intentionally exercises every field type, so the generic
39+
// `password` contract (plaintext at rest, masked on read — ADR-0100) is
40+
// deliberate here. Affirm it so the official example boots warning-free (#3420).
41+
f_password: Field.password({ label: 'Password (masked on read)', ackPlaintextMasking: true }),
3942
f_secret: Field.secret({ label: 'Secret (encrypted at rest)' }),
4043

4144
// ── Rich content ─────────────────────────────────────────────────────
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import stack from '../objectstack.config.js';
5+
6+
/**
7+
* #3420 — the official examples must boot with ZERO warnings so users don't get
8+
* trained to ignore them. A generic (non-`better-auth`) `password` field trips a
9+
* non-fatal `ObjectSchema.create()` warning at author time (ADR-0100: plaintext
10+
* at rest, masked on read); the documented way to affirm intent is
11+
* `ackPlaintextMasking: true`. This guard fails if a new or edited object
12+
* reintroduces an un-acknowledged generic password field — keeping the showcase
13+
* boot log clean without silencing the diagnostic for real authors.
14+
*
15+
* The other two boot-noise sources from #3420 are framework-level and guarded in
16+
* their own packages: the better-auth `oauthAuthServerConfig` false positive
17+
* (@objectstack/plugin-auth auth-manager.mcp-oauth.test.ts) and the registry
18+
* re-register lines (@objectstack/objectql registry-log-level.test.ts).
19+
*/
20+
describe('showcase boots without ADR-0100 password warnings (#3420)', () => {
21+
it('every generic password field affirms ackPlaintextMasking', () => {
22+
const offenders: string[] = [];
23+
for (const obj of (stack.objects ?? []) as any[]) {
24+
if (obj?.managedBy === 'better-auth') continue;
25+
for (const [fieldName, def] of Object.entries((obj?.fields ?? {}) as Record<string, any>)) {
26+
if (def?.type === 'password' && def?.ackPlaintextMasking !== true) {
27+
offenders.push(`${obj.name}.${fieldName}`);
28+
}
29+
}
30+
}
31+
expect(offenders, `un-acknowledged generic password field(s): ${offenders.join(', ')}`).toEqual([]);
32+
});
33+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
4+
import { SchemaRegistry, REGISTRY_LOG_LEVELS } from './registry';
5+
6+
/**
7+
* #3420 — the registry's expected-but-noisy housekeeping (re-registering an
8+
* owned object, overwriting a package manifest on a rebuild / HMR / multi-project
9+
* seed-replay) must NOT reach a stock `os dev` boot log. It used to be emitted
10+
* via `console.warn` (always on) and looked like an error, though it is a normal
11+
* path. It is now emitted at `debug`, so it stays out of the default `info` level
12+
* but `OS_REGISTRY_LOG=debug` (or `{ logLevel: 'debug' }`) makes it discoverable.
13+
*
14+
* This is the regression guard: if either line ever regresses back to `warn`
15+
* (or to the always-on `log`/`console.log`), the "silent at info" assertions
16+
* fail — keeping the official examples' boot log clean.
17+
*/
18+
describe('SchemaRegistry log-level gating (#3420)', () => {
19+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
20+
const debug = vi.spyOn(console, 'debug').mockImplementation(() => {});
21+
beforeEach(() => { warn.mockClear(); debug.mockClear(); });
22+
afterEach(() => { delete process.env.OS_REGISTRY_LOG; });
23+
24+
const reRegisterSameOwner = (r: SchemaRegistry) => {
25+
r.registerObject({ name: 'sys_thing', fields: {} } as any, 'com.acme.app', 'sys', 'own');
26+
r.registerObject({ name: 'sys_thing', fields: {} } as any, 'com.acme.app', 'sys', 'own');
27+
};
28+
29+
it('at the default (info) level, re-registering an owned object is silent — no warn, no debug', () => {
30+
const r = new SchemaRegistry({ multiTenant: false });
31+
reRegisterSameOwner(r);
32+
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('Re-registering owned object'));
33+
expect(debug).not.toHaveBeenCalledWith(expect.stringContaining('Re-registering owned object'));
34+
});
35+
36+
it('at debug level, the re-register line is emitted via console.debug (never console.warn)', () => {
37+
const r = new SchemaRegistry({ multiTenant: false, logLevel: 'debug' });
38+
reRegisterSameOwner(r);
39+
expect(debug).toHaveBeenCalledWith(expect.stringContaining('Re-registering owned object: sys_thing'));
40+
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('Re-registering owned object'));
41+
});
42+
43+
it('overwriting a package manifest is debug-gated the same way', () => {
44+
const manifest = { id: 'com.test', name: 'Test', namespace: 'test', version: '1.0.0' } as any;
45+
46+
const info = new SchemaRegistry({ multiTenant: false });
47+
info.installPackage(manifest);
48+
info.installPackage(manifest); // same-package reload → "Overwriting package"
49+
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('Overwriting package'));
50+
expect(debug).not.toHaveBeenCalledWith(expect.stringContaining('Overwriting package'));
51+
52+
debug.mockClear();
53+
const dbg = new SchemaRegistry({ multiTenant: false, logLevel: 'debug' });
54+
dbg.installPackage(manifest);
55+
dbg.installPackage(manifest);
56+
expect(debug).toHaveBeenCalledWith(expect.stringContaining('Overwriting package: com.test'));
57+
});
58+
59+
it('resolves the level from OS_REGISTRY_LOG when no explicit option is given', () => {
60+
process.env.OS_REGISTRY_LOG = 'debug';
61+
expect(new SchemaRegistry({ multiTenant: false }).logLevel).toBe('debug');
62+
});
63+
64+
it('falls back to info for an unrecognized OS_REGISTRY_LOG value', () => {
65+
process.env.OS_REGISTRY_LOG = 'chatty';
66+
expect(new SchemaRegistry({ multiTenant: false }).logLevel).toBe('info');
67+
});
68+
69+
it('an explicit logLevel option wins over the env var', () => {
70+
process.env.OS_REGISTRY_LOG = 'debug';
71+
expect(new SchemaRegistry({ multiTenant: false, logLevel: 'silent' }).logLevel).toBe('silent');
72+
});
73+
74+
it('exposes the full level vocabulary for validation', () => {
75+
expect(REGISTRY_LOG_LEVELS).toEqual(['debug', 'info', 'warn', 'error', 'silent']);
76+
});
77+
});

packages/objectql/src/registry.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,11 @@ function mergeObjectDefinitions(base: ServiceObject, extension: Partial<ServiceO
121121
*/
122122
export type RegistryLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent';
123123

124+
/** All valid {@link RegistryLogLevel} values — used to validate `OS_REGISTRY_LOG`. */
125+
export const REGISTRY_LOG_LEVELS: readonly RegistryLogLevel[] = [
126+
'debug', 'info', 'warn', 'error', 'silent',
127+
];
128+
124129
/**
125130
* Construction options for {@link SchemaRegistry}.
126131
*/
@@ -172,6 +177,19 @@ export interface SchemaRegistryOptions {
172177
* package ids are always disambiguable by package-scoped resolution.)
173178
*/
174179
collisionPolicy?: 'error' | 'warn';
180+
181+
/**
182+
* Verbosity of the registry's own `[Registry] …` log lines. Sourced from the
183+
* `OS_REGISTRY_LOG` env var when not set explicitly (default `'info'`).
184+
*
185+
* The point of the env seam is discoverability (#3420): expected-but-noisy
186+
* housekeeping — re-registering an owned object / overwriting a package
187+
* manifest on a rebuild/HMR/seed-replay — is emitted at `'debug'`, so it stays
188+
* out of a stock `info` boot log but a developer chasing a registration issue
189+
* can surface it with `OS_REGISTRY_LOG=debug`. An unrecognized value falls
190+
* back to `'info'`.
191+
*/
192+
logLevel?: RegistryLogLevel;
175193
}
176194

177195
/**
@@ -529,6 +547,16 @@ export class SchemaRegistry {
529547
this.collisionPolicy =
530548
options.collisionPolicy ??
531549
((process.env.OS_METADATA_COLLISION ?? '').toLowerCase() === 'warn' ? 'warn' : 'error');
550+
551+
// #3420 — env-driven verbosity so debug-level registry housekeeping
552+
// (re-register / package overwrite) is discoverable without a code change.
553+
// Unrecognized OS_REGISTRY_LOG values fall back to the 'info' default.
554+
const envLevel = (process.env.OS_REGISTRY_LOG ?? '').toLowerCase();
555+
this._logLevel =
556+
options.logLevel ??
557+
(REGISTRY_LOG_LEVELS.includes(envLevel as RegistryLogLevel)
558+
? (envLevel as RegistryLogLevel)
559+
: this._logLevel);
532560
}
533561

534562
get logLevel(): RegistryLogLevel { return this._logLevel; }
@@ -539,6 +567,17 @@ export class SchemaRegistry {
539567
console.log(msg);
540568
}
541569

570+
/**
571+
* Debug-only diagnostic: emitted solely when `logLevel === 'debug'`, so it
572+
* stays out of the default (`'info'`) boot log. Use for expected-but-noisy
573+
* housekeeping — e.g. re-registration on a metadata rebuild / HMR reload,
574+
* which looks like an error (`console.warn`) but is a normal path (#3420).
575+
*/
576+
private debug(msg: string): void {
577+
if (this._logLevel !== 'debug') return;
578+
console.debug(msg);
579+
}
580+
542581
// ==========================================
543582
// Object-specific storage (Ownership Model)
544583
// ==========================================
@@ -731,7 +770,10 @@ export class SchemaRegistry {
731770
const idx = contributors.findIndex(c => c.packageId === packageId && c.ownership === 'own');
732771
if (idx !== -1) {
733772
contributors.splice(idx, 1);
734-
console.warn(`[Registry] Re-registering owned object: ${fqn} from ${packageId}`);
773+
// Normal path (metadata rebuild / HMR / multi-project seed replays the
774+
// same owned object), not an error — keep it at debug so a stock boot
775+
// stays warning-free (#3420).
776+
this.debug(`[Registry] Re-registering owned object: ${fqn} from ${packageId}`);
735777
}
736778
} else {
737779
// extend mode: remove existing extension from same package
@@ -1329,7 +1371,9 @@ export class SchemaRegistry {
13291371
}
13301372
const collection = this.metadata.get('package')!;
13311373
if (collection.has(manifest.id)) {
1332-
console.warn(`[Registry] Overwriting package: ${manifest.id}`);
1374+
// Re-install of an already-registered package manifest (rebuild / HMR) is
1375+
// a normal path, not an error — keep it at debug (#3420).
1376+
this.debug(`[Registry] Overwriting package: ${manifest.id}`);
13331377
}
13341378
collection.set(manifest.id, pkg);
13351379
this.log(`[Registry] Installed package: ${manifest.id} (${manifest.name})`);

packages/plugins/plugin-auth/src/auth-manager.mcp-oauth.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,16 @@ describe('oauthProvider plugin wiring (DCR + scopes + audiences)', () => {
302302
expect(opts.validAudiences).toContain('https://acme.example.com/api/v1/auth');
303303
});
304304

305+
it('silences the false-positive oauthAuthServerConfig warning (#3420)', async () => {
306+
// registerOidcDiscoveryRoutes mounts /.well-known/oauth-authorization-server
307+
// (and the /api/v1/auth path-insertion variant) at the issuer ROOT ourselves,
308+
// so better-auth's boot-time "Please ensure … exists" reminder is a false
309+
// positive. It must be silenced via the documented option, or the stock
310+
// showcase prints it (twice) on every `os dev`. Regression guard for the fix.
311+
const opts = await capturePluginOpts({ OS_MCP_SERVER_ENABLED: 'true' });
312+
expect(opts.silenceWarnings).toEqual({ oauthAuthServerConfig: true });
313+
});
314+
305315
it('OS_OIDC_DCR_ENABLED=false forces DCR off even with MCP on', async () => {
306316
const opts = await capturePluginOpts({ OS_MCP_SERVER_ENABLED: 'true', OS_OIDC_DCR_ENABLED: 'false' });
307317
expect(opts.allowDynamicClientRegistration).toBe(false);

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1849,6 +1849,23 @@ export class AuthManager {
18491849
loginPage: this.getConsolePageUrl('/login'),
18501850
consentPage: this.getConsolePageUrl('/oauth/consent'),
18511851
schema: buildOauthProviderPluginSchema(),
1852+
// better-auth's oauth-provider cannot see the well-known documents we
1853+
// mount ourselves at the issuer ROOT (RFC 8414 §3 requires them there,
1854+
// not under the auth basePath) — registerOidcDiscoveryRoutes serves
1855+
// /.well-known/oauth-authorization-server AND the path-insertion variant
1856+
// (`…/api/v1/auth`) the notice names. Its "Please ensure … exists"
1857+
// reminder is therefore a false positive on every stock example.
1858+
//
1859+
// #3420 root cause of the DOUBLE print: the notice fires in the
1860+
// oauth-provider plugin's `init(ctx)`, which better-auth runs once per
1861+
// `betterAuth()` construction — and the instance is built more than once
1862+
// at boot (an initial lazy build, then a rebuild once boot-time auth
1863+
// *settings* are applied — applyConfigPatch() nulls the cached instance
1864+
// so the next request rebuilds with the new policy). Gating the emitter
1865+
// here silences the one requirement we've already satisfied across every
1866+
// build path, independent of how many times auth is constructed, so an
1867+
// official dev boot stays warning-free.
1868+
silenceWarnings: { oauthAuthServerConfig: true },
18521869
// ── MCP OAuth track (#2698) ────────────────────────────────
18531870
// Coarse tool-family scopes for the platform's own MCP endpoint,
18541871
// advertised alongside the standard OIDC scopes. Names are

0 commit comments

Comments
 (0)