From e5e595d8d81ac129d4497add3ee9b70747423b4d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 14:57:16 +0000 Subject: [PATCH 1/4] fix(dev): eliminate three fixed startup log warnings so official examples boot clean (#3420) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `os dev` on the stock showcase printed three fixed noise sources on every boot, training users to ignore warnings (cry-wolf). Clear all three: 1. field-zoo password warning — ObjectSchema.create() warned that showcase_field_zoo.f_password declares `password` on a non-auth object, yet offered no way to express the "this is intended" its own text invited. Add a field-level `ackPlaintextMasking: true` opt-out (ADR-0100) that skips the warning for a deliberately-masked field, and set it on field-zoo's demo field. The warning text now points authors at the flag. 2. Better Auth well-known warning (printed twice) — @better-auth/oauth-provider warned "Please ensure '/.well-known/oauth-authorization-server/api/v1/auth' exists…" even though registerOidcDiscoveryRoutes already mounts those documents at the issuer root (RFC 8414 §3). Silence the false positive with the documented `silenceWarnings.oauthAuthServerConfig` option; gating the emitter also removes the duplicate print. 3. Registry re-register output — `[Registry] Overwriting package…` and `Re-registering owned object…` are normal rebuild/HMR/seed-replay paths but were emitted via console.warn (always on). Route them through a new debug-only SchemaRegistry.debug() so they stay out of the default 'info' boot log while remaining available at logLevel 'debug'. Adds spec tests for the ackPlaintextMasking opt-out (suppress, partial-ack, warning-text hint); updates ADR-0100 §B5 to record the opt-out. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TXHXXHnquzVUjeQYPs9bmy --- docs/adr/0100-credential-field-channels.md | 9 +++++ .../src/data/objects/field-zoo.object.ts | 5 ++- packages/objectql/src/registry.ts | 20 +++++++++-- .../plugins/plugin-auth/src/auth-manager.ts | 10 ++++++ packages/spec/src/data/field.zod.ts | 12 +++++++ packages/spec/src/data/object.test.ts | 34 +++++++++++++++++++ packages/spec/src/data/object.zod.ts | 13 ++++--- 7 files changed, 96 insertions(+), 7 deletions(-) diff --git a/docs/adr/0100-credential-field-channels.md b/docs/adr/0100-credential-field-channels.md index 186c61b9f8..9943bbe70a 100644 --- a/docs/adr/0100-credential-field-channels.md +++ b/docs/adr/0100-credential-field-channels.md @@ -95,6 +95,15 @@ rest but masked on read**: would be self-inflicted breakage. Raw `.parse()` stays silent, since it also loads persisted metadata and `create()` is the authoring surface (ADR-0077). + **Opt-out — `ackPlaintextMasking: true` (#3420).** A deliberate `password` + field (like field-zoo's `f_password`) can affirm intent with a field-level + `ackPlaintextMasking: true`; the warning then skips that field. The original + text said the warning was "safe to ignore" but offered no way to *express* + that intent, so the official showcase booted with an unavoidable warning — + training users to ignore warnings. The flag is the documented affirmation and + lets the stock example start warning-free. It is diagnostic-only: masking, + the echoed-mask guard, and the better-auth exemption are all unchanged by it. + ### C. Shared mechanism Both channels share one read-mask collector — `collectMaskedReadFields` diff --git a/examples/app-showcase/src/data/objects/field-zoo.object.ts b/examples/app-showcase/src/data/objects/field-zoo.object.ts index c311521c06..d20ae32827 100644 --- a/examples/app-showcase/src/data/objects/field-zoo.object.ts +++ b/examples/app-showcase/src/data/objects/field-zoo.object.ts @@ -35,7 +35,10 @@ export const FieldZoo = ObjectSchema.create({ f_email: Field.email({ label: 'Email', searchable: true }), f_url: Field.url({ label: 'URL' }), f_phone: Field.phone({ label: 'Phone' }), - f_password: Field.password({ label: 'Password (masked on read)' }), + // field-zoo intentionally exercises every field type, so the generic + // `password` contract (plaintext at rest, masked on read — ADR-0100) is + // deliberate here. Affirm it so the official example boots warning-free (#3420). + f_password: Field.password({ label: 'Password (masked on read)', ackPlaintextMasking: true }), f_secret: Field.secret({ label: 'Secret (encrypted at rest)' }), // ── Rich content ───────────────────────────────────────────────────── diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index c4649d0e2b..5edc930f52 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -539,6 +539,17 @@ export class SchemaRegistry { console.log(msg); } + /** + * Debug-only diagnostic: emitted solely when `logLevel === 'debug'`, so it + * stays out of the default (`'info'`) boot log. Use for expected-but-noisy + * housekeeping — e.g. re-registration on a metadata rebuild / HMR reload, + * which looks like an error (`console.warn`) but is a normal path (#3420). + */ + private debug(msg: string): void { + if (this._logLevel !== 'debug') return; + console.debug(msg); + } + // ========================================== // Object-specific storage (Ownership Model) // ========================================== @@ -731,7 +742,10 @@ export class SchemaRegistry { const idx = contributors.findIndex(c => c.packageId === packageId && c.ownership === 'own'); if (idx !== -1) { contributors.splice(idx, 1); - console.warn(`[Registry] Re-registering owned object: ${fqn} from ${packageId}`); + // Normal path (metadata rebuild / HMR / multi-project seed replays the + // same owned object), not an error — keep it at debug so a stock boot + // stays warning-free (#3420). + this.debug(`[Registry] Re-registering owned object: ${fqn} from ${packageId}`); } } else { // extend mode: remove existing extension from same package @@ -1329,7 +1343,9 @@ export class SchemaRegistry { } const collection = this.metadata.get('package')!; if (collection.has(manifest.id)) { - console.warn(`[Registry] Overwriting package: ${manifest.id}`); + // Re-install of an already-registered package manifest (rebuild / HMR) is + // a normal path, not an error — keep it at debug (#3420). + this.debug(`[Registry] Overwriting package: ${manifest.id}`); } collection.set(manifest.id, pkg); this.log(`[Registry] Installed package: ${manifest.id} (${manifest.name})`); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 7472a71108..02c7dc84e6 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -1849,6 +1849,16 @@ export class AuthManager { loginPage: this.getConsolePageUrl('/login'), consentPage: this.getConsolePageUrl('/oauth/consent'), schema: buildOauthProviderPluginSchema(), + // better-auth's oauth-provider cannot see the well-known documents we + // mount ourselves at the issuer ROOT (RFC 8414 §3 requires them there, + // not under the auth basePath) — registerOidcDiscoveryRoutes serves + // /.well-known/oauth-authorization-server AND the path-insertion variant + // (`…/api/v1/auth`) the notice names. Its boot-time "Please ensure … + // exists" reminder is therefore a false positive that fires on every + // stock example (twice — jwt+oauthProvider init runs it per instance); + // silence the one requirement we've already satisfied so an official + // dev boot stays warning-free (#3420). + silenceWarnings: { oauthAuthServerConfig: true }, // ── MCP OAuth track (#2698) ──────────────────────────────── // Coarse tool-family scopes for the platform's own MCP endpoint, // advertised alongside the standard OIDC scopes. Names are diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 477bfa0eb7..339689fca2 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -511,6 +511,18 @@ export const FieldSchema = lazySchema(() => z.object({ * over permission-set field grants. Enforced by plugin-security's FieldMasker. */ requiredPermissions: z.array(z.string()).optional().describe('[ADR-0066 D3] Capabilities required to read/edit this field (mask on read, deny on write; AND-gate).'), + + /** + * [ADR-0100] Author's explicit acknowledgment that a generic (non-auth) + * `password` field is stored PLAINTEXT at rest and masked to SECRET_MASK on + * read — it is NOT one-way hashed (that lives only in the auth subsystem). + * Set `true` to affirm the masking contract is intended; this is the + * documented way to express "this is intended" and silences the non-fatal + * `ObjectSchema.create()` author-time warning so a deliberate demo/design + * starts clean (#3420). No runtime effect beyond the diagnostic; ignored on + * non-`password` fields. + */ + ackPlaintextMasking: z.boolean().optional().describe("[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."), system: z.boolean().optional().describe('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.'), sortable: z.boolean().optional().default(true).describe('Whether field is sortable in list views'), inlineHelpText: z.string().optional().describe('Help text displayed below the field in forms'), diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 3779b39b15..641ffe05f2 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -1486,4 +1486,38 @@ describe('ObjectSchema.create() password-field author warning (ADR-0100)', () => }); expect(warn).not.toHaveBeenCalled(); }); + + it('does NOT warn when the field affirms intent with ackPlaintextMasking (#3420)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + ObjectSchema.create({ + name: 'adr0100_acked_pw', + fields: { admin_password: { type: 'password', ackPlaintextMasking: true } }, + }); + expect(warn).not.toHaveBeenCalled(); + }); + + it('still warns about un-acknowledged password fields when only some opt in', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + ObjectSchema.create({ + name: 'adr0100_partial_ack', + fields: { + acked_pw: { type: 'password', ackPlaintextMasking: true }, + raw_pw: { type: 'password' }, + }, + }); + expect(warn).toHaveBeenCalledTimes(1); + const msg = warn.mock.calls[0]?.[0] as string; + expect(msg).toContain('raw_pw'); + expect(msg).not.toContain('acked_pw'); + }); + + it('points authors at the ackPlaintextMasking opt-out in the warning text', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + ObjectSchema.create({ + name: 'adr0100_hint_pw', + fields: { pw: { type: 'password' } }, + }); + const msg = warn.mock.calls[0]?.[0] as string; + expect(msg).toContain('ackPlaintextMasking'); + }); }); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 74836db358..e1654af952 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1262,7 +1262,10 @@ const warnedPasswordObjects = new Set(); * A warning, not an error: `password` now has a defined generic-path contract, * and the field-zoo example intentionally exercises every field type — a hard * error would be self-inflicted breakage. Deduped per object name so a schema - * imported many times warns once. `managedBy: 'better-auth'` objects are exempt. + * imported many times warns once. `managedBy: 'better-auth'` objects are exempt, + * as are fields that opt in with `ackPlaintextMasking: true` — the author's + * explicit "this is intended" acknowledgment (#3420), which lets a deliberate + * demo/design (e.g. the showcase field-zoo) start with zero warnings. */ function warnGenericPasswordFields( objectName: unknown, @@ -1271,8 +1274,10 @@ function warnGenericPasswordFields( ): void { if (managedBy === 'better-auth') return; if (!fields || typeof fields !== 'object') return; - const passwordFields = Object.entries(fields as Record) - .filter(([, def]) => def && def.type === 'password') + const passwordFields = Object.entries( + fields as Record, + ) + .filter(([, def]) => def && def.type === 'password' && def.ackPlaintextMasking !== true) .map(([fieldName]) => fieldName); if (passwordFields.length === 0) return; const name = typeof objectName === 'string' && objectName.length > 0 ? objectName : ''; @@ -1285,7 +1290,7 @@ function warnGenericPasswordFields( 'it is NOT one-way hashed (that is owned by the auth subsystem, for its identity ' + "tables only). Use `Field.secret(...)` for reversible machine credentials, or model " + 'login credentials on the auth user object. If this is intended, the masking contract ' + - 'now applies and this warning is safe to ignore.', + 'applies — affirm it with `ackPlaintextMasking: true` on the field to silence this warning.', ); } From 69eea6c786250226b55ee820b3e57a36c86d47cc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 15:01:49 +0000 Subject: [PATCH 2/4] chore(spec): regen field reference doc, classify ackPlaintextMasking liveness, add changeset (#3420) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups required by CI for the new field-level `ackPlaintextMasking` option: - Regenerate content/docs/references/data/field.mdx (generated from FieldSchema). - Classify field/ackPlaintextMasking as `live` in the spec liveness ledger — its consumer is warnGenericPasswordFields (object.zod.ts), proven by the ADR-0100 author-warning suite in object.test.ts. - Add the changeset for @objectstack/spec, @objectstack/objectql, @objectstack/plugin-auth. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TXHXXHnquzVUjeQYPs9bmy --- .changeset/startup-log-noise-cleanup.md | 24 ++++++++++++++++++++++++ content/docs/references/data/field.mdx | 1 + packages/spec/liveness/field.json | 5 +++++ 3 files changed, 30 insertions(+) create mode 100644 .changeset/startup-log-noise-cleanup.md diff --git a/.changeset/startup-log-noise-cleanup.md b/.changeset/startup-log-noise-cleanup.md new file mode 100644 index 0000000000..2e99dd483b --- /dev/null +++ b/.changeset/startup-log-noise-cleanup.md @@ -0,0 +1,24 @@ +--- +"@objectstack/spec": patch +"@objectstack/objectql": patch +"@objectstack/plugin-auth": patch +--- + +fix(dev): eliminate three fixed startup log warnings so official examples boot clean (#3420) + +`os dev` on the stock showcase printed three fixed noise sources on every boot, +with zero example-side changes — training users to ignore warnings. + +- **spec** — add a field-level `ackPlaintextMasking: true` opt-out for the + generic `password` author-time warning (ADR-0100). A deliberately-masked + field (like field-zoo's `f_password`) can now affirm intent instead of + printing an un-actionable "safe to ignore" on every boot; the warning text + points authors at the flag. +- **plugin-auth** — pass better-auth's documented + `silenceWarnings.oauthAuthServerConfig` to `oauthProvider(...)`. We already + mount the `/.well-known/oauth-authorization-server` documents ourselves at + the issuer root, so the plugin's "please ensure it exists" reminder was a + false positive (printed twice); silencing it removes both. +- **objectql** — route the Registry's re-register / package-overwrite lines + (normal rebuild / HMR / seed-replay paths) through a new debug-only + `SchemaRegistry.debug()` so they stay out of the default `info` boot log. diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 359200dec8..0b55359f8c 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -147,6 +147,7 @@ const result = Address.parse(data); | **hidden** | `boolean` | optional | Hidden from default UI | | **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. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D3] Capabilities required to read/edit this field (mask on read, deny on write; AND-gate). | +| **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. | | **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. | | **sortable** | `boolean` | optional | Whether field is sortable in list views | | **inlineHelpText** | `string` | optional | Help text displayed below the field in forms | diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 775eb3e1e3..f327f17172 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -104,6 +104,11 @@ "evidence": "packages/plugins/plugin-security/src/security-plugin.ts", "note": "ADR-0066 D3 field-level capability gate. getObjectSecurityMeta reads field.requiredPermissions; foldFieldRequiredPermissions forces non-readable+non-editable when the caller's systemPermissions don't cover them, so FieldMasker masks on read + detectForbiddenWrites denies on write (AND-gate). Unit-proven in packages/plugins/plugin-security/src/security-plugin.test.ts." }, + "ackPlaintextMasking": { + "status": "live", + "evidence": "packages/spec/src/data/object.zod.ts", + "note": "ADR-0100 author-time diagnostic gate (#3420). warnGenericPasswordFields (object.zod.ts) reads field.ackPlaintextMasking and skips the generic-password plaintext-at-rest/masked-on-read warning for a field that affirms intent, so a deliberate demo (showcase field-zoo f_password, which exercises every field type) boots warning-free instead of printing an un-actionable 'safe to ignore'. Diagnostic-only: masking, the echoed-mask write guard, and the better-auth exemption are unchanged. Proven in packages/spec/src/data/object.test.ts (ADR-0100 password-field author warning suite: ackPlaintextMasking suppresses, partial-ack still warns about the un-acked field, warning text names the flag)." + }, "system": { "status": "live", "evidence": "packages/objectql/src/engine.ts" From 4d8b44e20b8cb71b325f75ed24d02b9795f3bf8c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 15:29:24 +0000 Subject: [PATCH 3/4] test(dev): zero-warning-boot regression guards + registry log-level env seam (#3420) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups so the startup log stays at zero warnings and the debug output is discoverable: 1. Regression guards for all three noise sources: - password: examples assert every generic (non-better-auth) `password` field affirms `ackPlaintextMasking` (showcase no-startup-warnings.test.ts; crm smoke.test.ts — future-proofs an example with none today). - better-auth: auth-manager.mcp-oauth.test.ts asserts oauthProvider is wired with `silenceWarnings.oauthAuthServerConfig: true`. - registry: registry-log-level.test.ts asserts the re-register / package- overwrite lines are silent at the default `info` level and only emit via console.debug at `debug` (never console.warn). 2. Other examples: verified only showcase declared a generic password field; the better-auth and registry fixes are framework-level so crm/todo inherit them. crm now carries the same password guard. (todo ships no password fields and uses the custom `objectstack test` runner, so no vitest guard is added there.) 3. better-auth double-print root cause: the notice fires in oauth-provider's `init(ctx)`, run once per betterAuth() construction; auth is (re)built more than once at boot (initial lazy build, then a rebuild after boot-time auth settings apply — applyConfigPatch nulls the cached instance). silenceWarnings gates the emitter itself, so it is covered on every build path. Corrected the inaccurate inline comment accordingly. 4. Registry debug discoverability: add a `logLevel` option to SchemaRegistryOptions resolved from `OS_REGISTRY_LOG` (unknown → info), so a developer can surface the debug-gated housekeeping with OS_REGISTRY_LOG=debug. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TXHXXHnquzVUjeQYPs9bmy --- examples/app-crm/test/smoke.test.ts | 17 ++++ .../test/no-startup-warnings.test.ts | 33 ++++++++ .../objectql/src/registry-log-level.test.ts | 77 +++++++++++++++++++ packages/objectql/src/registry.ts | 28 +++++++ .../src/auth-manager.mcp-oauth.test.ts | 10 +++ .../plugins/plugin-auth/src/auth-manager.ts | 17 ++-- 6 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 examples/app-showcase/test/no-startup-warnings.test.ts create mode 100644 packages/objectql/src/registry-log-level.test.ts diff --git a/examples/app-crm/test/smoke.test.ts b/examples/app-crm/test/smoke.test.ts index 2d4b52715a..ac35724410 100644 --- a/examples/app-crm/test/smoke.test.ts +++ b/examples/app-crm/test/smoke.test.ts @@ -95,6 +95,23 @@ describe('app-crm minimal metadata bundle', () => { expect(rules.some((r) => r.type === 'owner')).toBe(true); }); + // #3420 — official examples must boot warning-free. A generic (non-better-auth) + // `password` field trips the ADR-0100 author-time warning unless it affirms + // intent with `ackPlaintextMasking: true`. crm ships none today; this guard + // fails loudly if one is ever added without the acknowledgment. + it('has no un-acknowledged generic password fields (#3420)', () => { + const offenders: string[] = []; + for (const obj of (stack.objects ?? []) as any[]) { + if (obj?.managedBy === 'better-auth') continue; + for (const [fieldName, def] of Object.entries((obj?.fields ?? {}) as Record)) { + if (def?.type === 'password' && def?.ackPlaintextMasking !== true) { + offenders.push(`${obj.name}.${fieldName}`); + } + } + } + expect(offenders, `un-acknowledged generic password field(s): ${offenders.join(', ')}`).toEqual([]); + }); + }); describe('Pipeline dashboard', () => { diff --git a/examples/app-showcase/test/no-startup-warnings.test.ts b/examples/app-showcase/test/no-startup-warnings.test.ts new file mode 100644 index 0000000000..e4ee937a1b --- /dev/null +++ b/examples/app-showcase/test/no-startup-warnings.test.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import stack from '../objectstack.config.js'; + +/** + * #3420 — the official examples must boot with ZERO warnings so users don't get + * trained to ignore them. A generic (non-`better-auth`) `password` field trips a + * non-fatal `ObjectSchema.create()` warning at author time (ADR-0100: plaintext + * at rest, masked on read); the documented way to affirm intent is + * `ackPlaintextMasking: true`. This guard fails if a new or edited object + * reintroduces an un-acknowledged generic password field — keeping the showcase + * boot log clean without silencing the diagnostic for real authors. + * + * The other two boot-noise sources from #3420 are framework-level and guarded in + * their own packages: the better-auth `oauthAuthServerConfig` false positive + * (@objectstack/plugin-auth auth-manager.mcp-oauth.test.ts) and the registry + * re-register lines (@objectstack/objectql registry-log-level.test.ts). + */ +describe('showcase boots without ADR-0100 password warnings (#3420)', () => { + it('every generic password field affirms ackPlaintextMasking', () => { + const offenders: string[] = []; + for (const obj of (stack.objects ?? []) as any[]) { + if (obj?.managedBy === 'better-auth') continue; + for (const [fieldName, def] of Object.entries((obj?.fields ?? {}) as Record)) { + if (def?.type === 'password' && def?.ackPlaintextMasking !== true) { + offenders.push(`${obj.name}.${fieldName}`); + } + } + } + expect(offenders, `un-acknowledged generic password field(s): ${offenders.join(', ')}`).toEqual([]); + }); +}); diff --git a/packages/objectql/src/registry-log-level.test.ts b/packages/objectql/src/registry-log-level.test.ts new file mode 100644 index 0000000000..637c84db4e --- /dev/null +++ b/packages/objectql/src/registry-log-level.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { SchemaRegistry, REGISTRY_LOG_LEVELS } from './registry'; + +/** + * #3420 — the registry's expected-but-noisy housekeeping (re-registering an + * owned object, overwriting a package manifest on a rebuild / HMR / multi-project + * seed-replay) must NOT reach a stock `os dev` boot log. It used to be emitted + * via `console.warn` (always on) and looked like an error, though it is a normal + * path. It is now emitted at `debug`, so it stays out of the default `info` level + * but `OS_REGISTRY_LOG=debug` (or `{ logLevel: 'debug' }`) makes it discoverable. + * + * This is the regression guard: if either line ever regresses back to `warn` + * (or to the always-on `log`/`console.log`), the "silent at info" assertions + * fail — keeping the official examples' boot log clean. + */ +describe('SchemaRegistry log-level gating (#3420)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const debug = vi.spyOn(console, 'debug').mockImplementation(() => {}); + beforeEach(() => { warn.mockClear(); debug.mockClear(); }); + afterEach(() => { delete process.env.OS_REGISTRY_LOG; }); + + const reRegisterSameOwner = (r: SchemaRegistry) => { + r.registerObject({ name: 'sys_thing', fields: {} } as any, 'com.acme.app', 'sys', 'own'); + r.registerObject({ name: 'sys_thing', fields: {} } as any, 'com.acme.app', 'sys', 'own'); + }; + + it('at the default (info) level, re-registering an owned object is silent — no warn, no debug', () => { + const r = new SchemaRegistry({ multiTenant: false }); + reRegisterSameOwner(r); + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('Re-registering owned object')); + expect(debug).not.toHaveBeenCalledWith(expect.stringContaining('Re-registering owned object')); + }); + + it('at debug level, the re-register line is emitted via console.debug (never console.warn)', () => { + const r = new SchemaRegistry({ multiTenant: false, logLevel: 'debug' }); + reRegisterSameOwner(r); + expect(debug).toHaveBeenCalledWith(expect.stringContaining('Re-registering owned object: sys_thing')); + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('Re-registering owned object')); + }); + + it('overwriting a package manifest is debug-gated the same way', () => { + const manifest = { id: 'com.test', name: 'Test', namespace: 'test', version: '1.0.0' } as any; + + const info = new SchemaRegistry({ multiTenant: false }); + info.installPackage(manifest); + info.installPackage(manifest); // same-package reload → "Overwriting package" + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('Overwriting package')); + expect(debug).not.toHaveBeenCalledWith(expect.stringContaining('Overwriting package')); + + debug.mockClear(); + const dbg = new SchemaRegistry({ multiTenant: false, logLevel: 'debug' }); + dbg.installPackage(manifest); + dbg.installPackage(manifest); + expect(debug).toHaveBeenCalledWith(expect.stringContaining('Overwriting package: com.test')); + }); + + it('resolves the level from OS_REGISTRY_LOG when no explicit option is given', () => { + process.env.OS_REGISTRY_LOG = 'debug'; + expect(new SchemaRegistry({ multiTenant: false }).logLevel).toBe('debug'); + }); + + it('falls back to info for an unrecognized OS_REGISTRY_LOG value', () => { + process.env.OS_REGISTRY_LOG = 'chatty'; + expect(new SchemaRegistry({ multiTenant: false }).logLevel).toBe('info'); + }); + + it('an explicit logLevel option wins over the env var', () => { + process.env.OS_REGISTRY_LOG = 'debug'; + expect(new SchemaRegistry({ multiTenant: false, logLevel: 'silent' }).logLevel).toBe('silent'); + }); + + it('exposes the full level vocabulary for validation', () => { + expect(REGISTRY_LOG_LEVELS).toEqual(['debug', 'info', 'warn', 'error', 'silent']); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 5edc930f52..53a3718e42 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -121,6 +121,11 @@ function mergeObjectDefinitions(base: ServiceObject, extension: Partial { expect(opts.validAudiences).toContain('https://acme.example.com/api/v1/auth'); }); + it('silences the false-positive oauthAuthServerConfig warning (#3420)', async () => { + // registerOidcDiscoveryRoutes mounts /.well-known/oauth-authorization-server + // (and the /api/v1/auth path-insertion variant) at the issuer ROOT ourselves, + // so better-auth's boot-time "Please ensure … exists" reminder is a false + // positive. It must be silenced via the documented option, or the stock + // showcase prints it (twice) on every `os dev`. Regression guard for the fix. + const opts = await capturePluginOpts({ OS_MCP_SERVER_ENABLED: 'true' }); + expect(opts.silenceWarnings).toEqual({ oauthAuthServerConfig: true }); + }); + it('OS_OIDC_DCR_ENABLED=false forces DCR off even with MCP on', async () => { const opts = await capturePluginOpts({ OS_MCP_SERVER_ENABLED: 'true', OS_OIDC_DCR_ENABLED: 'false' }); expect(opts.allowDynamicClientRegistration).toBe(false); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 02c7dc84e6..3134ea9d2a 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -1853,11 +1853,18 @@ export class AuthManager { // mount ourselves at the issuer ROOT (RFC 8414 §3 requires them there, // not under the auth basePath) — registerOidcDiscoveryRoutes serves // /.well-known/oauth-authorization-server AND the path-insertion variant - // (`…/api/v1/auth`) the notice names. Its boot-time "Please ensure … - // exists" reminder is therefore a false positive that fires on every - // stock example (twice — jwt+oauthProvider init runs it per instance); - // silence the one requirement we've already satisfied so an official - // dev boot stays warning-free (#3420). + // (`…/api/v1/auth`) the notice names. Its "Please ensure … exists" + // reminder is therefore a false positive on every stock example. + // + // #3420 root cause of the DOUBLE print: the notice fires in the + // oauth-provider plugin's `init(ctx)`, which better-auth runs once per + // `betterAuth()` construction — and the instance is built more than once + // at boot (an initial lazy build, then a rebuild once boot-time auth + // *settings* are applied — applyConfigPatch() nulls the cached instance + // so the next request rebuilds with the new policy). Gating the emitter + // here silences the one requirement we've already satisfied across every + // build path, independent of how many times auth is constructed, so an + // official dev boot stays warning-free. silenceWarnings: { oauthAuthServerConfig: true }, // ── MCP OAuth track (#2698) ──────────────────────────────── // Coarse tool-family scopes for the platform's own MCP endpoint, From 9848a47707229269f3d40f8736267bd7d20165bf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 15:30:01 +0000 Subject: [PATCH 4/4] chore(changeset): note the new OS_REGISTRY_LOG option (#3420) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TXHXXHnquzVUjeQYPs9bmy --- .changeset/startup-log-noise-cleanup.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/startup-log-noise-cleanup.md b/.changeset/startup-log-noise-cleanup.md index 2e99dd483b..1671c820d7 100644 --- a/.changeset/startup-log-noise-cleanup.md +++ b/.changeset/startup-log-noise-cleanup.md @@ -21,4 +21,6 @@ with zero example-side changes — training users to ignore warnings. false positive (printed twice); silencing it removes both. - **objectql** — route the Registry's re-register / package-overwrite lines (normal rebuild / HMR / seed-replay paths) through a new debug-only - `SchemaRegistry.debug()` so they stay out of the default `info` boot log. + `SchemaRegistry.debug()` so they stay out of the default `info` boot log. Adds + a `logLevel` construction option (and matching `OS_REGISTRY_LOG` env var) so + the debug-gated housekeeping is discoverable for troubleshooting.