Skip to content

Commit e5e595d

Browse files
committed
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, 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TXHXXHnquzVUjeQYPs9bmy
1 parent 2ba560a commit e5e595d

7 files changed

Lines changed: 96 additions & 7 deletions

File tree

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-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 ─────────────────────────────────────────────────────

packages/objectql/src/registry.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,17 @@ export class SchemaRegistry {
539539
console.log(msg);
540540
}
541541

542+
/**
543+
* Debug-only diagnostic: emitted solely when `logLevel === 'debug'`, so it
544+
* stays out of the default (`'info'`) boot log. Use for expected-but-noisy
545+
* housekeeping — e.g. re-registration on a metadata rebuild / HMR reload,
546+
* which looks like an error (`console.warn`) but is a normal path (#3420).
547+
*/
548+
private debug(msg: string): void {
549+
if (this._logLevel !== 'debug') return;
550+
console.debug(msg);
551+
}
552+
542553
// ==========================================
543554
// Object-specific storage (Ownership Model)
544555
// ==========================================
@@ -731,7 +742,10 @@ export class SchemaRegistry {
731742
const idx = contributors.findIndex(c => c.packageId === packageId && c.ownership === 'own');
732743
if (idx !== -1) {
733744
contributors.splice(idx, 1);
734-
console.warn(`[Registry] Re-registering owned object: ${fqn} from ${packageId}`);
745+
// Normal path (metadata rebuild / HMR / multi-project seed replays the
746+
// same owned object), not an error — keep it at debug so a stock boot
747+
// stays warning-free (#3420).
748+
this.debug(`[Registry] Re-registering owned object: ${fqn} from ${packageId}`);
735749
}
736750
} else {
737751
// extend mode: remove existing extension from same package
@@ -1329,7 +1343,9 @@ export class SchemaRegistry {
13291343
}
13301344
const collection = this.metadata.get('package')!;
13311345
if (collection.has(manifest.id)) {
1332-
console.warn(`[Registry] Overwriting package: ${manifest.id}`);
1346+
// Re-install of an already-registered package manifest (rebuild / HMR) is
1347+
// a normal path, not an error — keep it at debug (#3420).
1348+
this.debug(`[Registry] Overwriting package: ${manifest.id}`);
13331349
}
13341350
collection.set(manifest.id, pkg);
13351351
this.log(`[Registry] Installed package: ${manifest.id} (${manifest.name})`);

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1849,6 +1849,16 @@ 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 boot-time "Please ensure …
1857+
// exists" reminder is therefore a false positive that fires on every
1858+
// stock example (twice — jwt+oauthProvider init runs it per instance);
1859+
// silence the one requirement we've already satisfied so an official
1860+
// dev boot stays warning-free (#3420).
1861+
silenceWarnings: { oauthAuthServerConfig: true },
18521862
// ── MCP OAuth track (#2698) ────────────────────────────────
18531863
// Coarse tool-family scopes for the platform's own MCP endpoint,
18541864
// advertised alongside the standard OIDC scopes. Names are

packages/spec/src/data/field.zod.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,18 @@ export const FieldSchema = lazySchema(() => z.object({
511511
* over permission-set field grants. Enforced by plugin-security's FieldMasker.
512512
*/
513513
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).'),
514+
515+
/**
516+
* [ADR-0100] Author's explicit acknowledgment that a generic (non-auth)
517+
* `password` field is stored PLAINTEXT at rest and masked to SECRET_MASK on
518+
* read — it is NOT one-way hashed (that lives only in the auth subsystem).
519+
* Set `true` to affirm the masking contract is intended; this is the
520+
* documented way to express "this is intended" and silences the non-fatal
521+
* `ObjectSchema.create()` author-time warning so a deliberate demo/design
522+
* starts clean (#3420). No runtime effect beyond the diagnostic; ignored on
523+
* non-`password` fields.
524+
*/
525+
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."),
514526
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.'),
515527
sortable: z.boolean().optional().default(true).describe('Whether field is sortable in list views'),
516528
inlineHelpText: z.string().optional().describe('Help text displayed below the field in forms'),

packages/spec/src/data/object.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1486,4 +1486,38 @@ describe('ObjectSchema.create() password-field author warning (ADR-0100)', () =>
14861486
});
14871487
expect(warn).not.toHaveBeenCalled();
14881488
});
1489+
1490+
it('does NOT warn when the field affirms intent with ackPlaintextMasking (#3420)', () => {
1491+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
1492+
ObjectSchema.create({
1493+
name: 'adr0100_acked_pw',
1494+
fields: { admin_password: { type: 'password', ackPlaintextMasking: true } },
1495+
});
1496+
expect(warn).not.toHaveBeenCalled();
1497+
});
1498+
1499+
it('still warns about un-acknowledged password fields when only some opt in', () => {
1500+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
1501+
ObjectSchema.create({
1502+
name: 'adr0100_partial_ack',
1503+
fields: {
1504+
acked_pw: { type: 'password', ackPlaintextMasking: true },
1505+
raw_pw: { type: 'password' },
1506+
},
1507+
});
1508+
expect(warn).toHaveBeenCalledTimes(1);
1509+
const msg = warn.mock.calls[0]?.[0] as string;
1510+
expect(msg).toContain('raw_pw');
1511+
expect(msg).not.toContain('acked_pw');
1512+
});
1513+
1514+
it('points authors at the ackPlaintextMasking opt-out in the warning text', () => {
1515+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
1516+
ObjectSchema.create({
1517+
name: 'adr0100_hint_pw',
1518+
fields: { pw: { type: 'password' } },
1519+
});
1520+
const msg = warn.mock.calls[0]?.[0] as string;
1521+
expect(msg).toContain('ackPlaintextMasking');
1522+
});
14891523
});

packages/spec/src/data/object.zod.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1262,7 +1262,10 @@ const warnedPasswordObjects = new Set<string>();
12621262
* A warning, not an error: `password` now has a defined generic-path contract,
12631263
* and the field-zoo example intentionally exercises every field type — a hard
12641264
* error would be self-inflicted breakage. Deduped per object name so a schema
1265-
* imported many times warns once. `managedBy: 'better-auth'` objects are exempt.
1265+
* imported many times warns once. `managedBy: 'better-auth'` objects are exempt,
1266+
* as are fields that opt in with `ackPlaintextMasking: true` — the author's
1267+
* explicit "this is intended" acknowledgment (#3420), which lets a deliberate
1268+
* demo/design (e.g. the showcase field-zoo) start with zero warnings.
12661269
*/
12671270
function warnGenericPasswordFields(
12681271
objectName: unknown,
@@ -1271,8 +1274,10 @@ function warnGenericPasswordFields(
12711274
): void {
12721275
if (managedBy === 'better-auth') return;
12731276
if (!fields || typeof fields !== 'object') return;
1274-
const passwordFields = Object.entries(fields as Record<string, { type?: string }>)
1275-
.filter(([, def]) => def && def.type === 'password')
1277+
const passwordFields = Object.entries(
1278+
fields as Record<string, { type?: string; ackPlaintextMasking?: boolean }>,
1279+
)
1280+
.filter(([, def]) => def && def.type === 'password' && def.ackPlaintextMasking !== true)
12761281
.map(([fieldName]) => fieldName);
12771282
if (passwordFields.length === 0) return;
12781283
const name = typeof objectName === 'string' && objectName.length > 0 ? objectName : '<unnamed>';
@@ -1285,7 +1290,7 @@ function warnGenericPasswordFields(
12851290
'it is NOT one-way hashed (that is owned by the auth subsystem, for its identity ' +
12861291
"tables only). Use `Field.secret(...)` for reversible machine credentials, or model " +
12871292
'login credentials on the auth user object. If this is intended, the masking contract ' +
1288-
'now applies and this warning is safe to ignore.',
1293+
'applies — affirm it with `ackPlaintextMasking: true` on the field to silence this warning.',
12891294
);
12901295
}
12911296

0 commit comments

Comments
 (0)