Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/startup-log-noise-cleanup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@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. Adds
a `logLevel` construction option (and matching `OS_REGISTRY_LOG` env var) so
the debug-gated housekeeping is discoverable for troubleshooting.
1 change: 1 addition & 0 deletions content/docs/references/data/field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
9 changes: 9 additions & 0 deletions docs/adr/0100-credential-field-channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
17 changes: 17 additions & 0 deletions examples/app-crm/test/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>)) {
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', () => {
Expand Down
5 changes: 4 additions & 1 deletion examples/app-showcase/src/data/objects/field-zoo.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────
Expand Down
33 changes: 33 additions & 0 deletions examples/app-showcase/test/no-startup-warnings.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>)) {
if (def?.type === 'password' && def?.ackPlaintextMasking !== true) {
offenders.push(`${obj.name}.${fieldName}`);
}
}
}
expect(offenders, `un-acknowledged generic password field(s): ${offenders.join(', ')}`).toEqual([]);
});
});
77 changes: 77 additions & 0 deletions packages/objectql/src/registry-log-level.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
48 changes: 46 additions & 2 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ function mergeObjectDefinitions(base: ServiceObject, extension: Partial<ServiceO
*/
export type RegistryLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent';

/** All valid {@link RegistryLogLevel} values — used to validate `OS_REGISTRY_LOG`. */
export const REGISTRY_LOG_LEVELS: readonly RegistryLogLevel[] = [
'debug', 'info', 'warn', 'error', 'silent',
];

/**
* Construction options for {@link SchemaRegistry}.
*/
Expand Down Expand Up @@ -172,6 +177,19 @@ export interface SchemaRegistryOptions {
* package ids are always disambiguable by package-scoped resolution.)
*/
collisionPolicy?: 'error' | 'warn';

/**
* Verbosity of the registry's own `[Registry] …` log lines. Sourced from the
* `OS_REGISTRY_LOG` env var when not set explicitly (default `'info'`).
*
* The point of the env seam is discoverability (#3420): expected-but-noisy
* housekeeping — re-registering an owned object / overwriting a package
* manifest on a rebuild/HMR/seed-replay — is emitted at `'debug'`, so it stays
* out of a stock `info` boot log but a developer chasing a registration issue
* can surface it with `OS_REGISTRY_LOG=debug`. An unrecognized value falls
* back to `'info'`.
*/
logLevel?: RegistryLogLevel;
}

/**
Expand Down Expand Up @@ -529,6 +547,16 @@ export class SchemaRegistry {
this.collisionPolicy =
options.collisionPolicy ??
((process.env.OS_METADATA_COLLISION ?? '').toLowerCase() === 'warn' ? 'warn' : 'error');

// #3420 — env-driven verbosity so debug-level registry housekeeping
// (re-register / package overwrite) is discoverable without a code change.
// Unrecognized OS_REGISTRY_LOG values fall back to the 'info' default.
const envLevel = (process.env.OS_REGISTRY_LOG ?? '').toLowerCase();
this._logLevel =
options.logLevel ??
(REGISTRY_LOG_LEVELS.includes(envLevel as RegistryLogLevel)
? (envLevel as RegistryLogLevel)
: this._logLevel);
}

get logLevel(): RegistryLogLevel { return this._logLevel; }
Expand All @@ -539,6 +567,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)
// ==========================================
Expand Down Expand Up @@ -731,7 +770,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
Expand Down Expand Up @@ -1329,7 +1371,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})`);
Expand Down
10 changes: 10 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.mcp-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,16 @@ describe('oauthProvider plugin wiring (DCR + scopes + audiences)', () => {
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);
Expand Down
17 changes: 17 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1849,6 +1849,23 @@ 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 "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,
// advertised alongside the standard OIDC scopes. Names are
Expand Down
5 changes: 5 additions & 0 deletions packages/spec/liveness/field.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading