diff --git a/.changeset/dev-loop-dx-p2.md b/.changeset/dev-loop-dx-p2.md new file mode 100644 index 0000000000..081ef6eaec --- /dev/null +++ b/.changeset/dev-loop-dx-p2.md @@ -0,0 +1,13 @@ +--- +"@objectstack/objectql": patch +"@objectstack/metadata": patch +"@objectstack/runtime": patch +"@objectstack/plugin-auth": patch +"@objectstack/cli": patch +--- + +Dev-loop DX fixes from the 15.1 third-party evaluation (P2 batch): + +- **Hot-added objects are now queryable without a restart.** Adding a `*.object.ts` under `os dev` used to recompile "green" while every query answered `no such table` (or `not registered`) until a manual restart: the artifact reload never notified the ObjectQL registry, tables were only created at boot, and seeds only loaded from the boot-time bundle. The `metadata:reloaded` payload now carries the parsed artifact; ObjectQL ingests the object definitions and re-runs the idempotent schema sync (same `skipSchemaSync` opt-out as boot), and the runtime loads seeds for first-seen objects (dev, single-tenant). `os dev` also prints `✚ new object(s): …` on recompile. +- **Dev admin credentials stay visible.** The `os dev` startup banner only showed `admin@objectos.ai / admin123` on the boot that actually seeded it; with the persistent default DB every later boot hid it, and the Console login page never knew it existed. The hint now re-arms on every dev boot for as long as the account still verifies against the default password, and `GET /api/v1/auth/config` exposes a dev-gated `devSeedAdmin` field (never present outside `NODE_ENV=development`) so the login page can show it. +- **`os doctor` reference analysis understands current metadata shapes.** Objects bound through `defineView` containers (`list`/`listViews`/`form`/`formViews` → `data.object`, subform `childObject`, lookup form fields) and app navigation (`objectName`, nested `children`, `areas`) were reported as "defined but not referenced". The collector now walks the canonical shapes (plus flow node `config.object`/`objectName`) and the orphan-view check descends into containers. diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 7afe8f4956..9eb80a0fc7 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -392,6 +392,26 @@ export default class Dev extends Command { let inFlight = false; let queued = false; + // Object-name inventory of the artifact, diffed across recompiles so a + // newly added *.object.ts is called out explicitly (15.1 third-party + // eval: "recompiled" alone read as all-green while the new object's + // table/seed sync was invisible to the user). + const readArtifactObjects = (): Set | null => { + try { + const raw = JSON.parse(fs.readFileSync(opts.artifactPath, 'utf8')); + const meta = raw?.metadata ?? raw?.data?.metadata ?? raw; + const objects = Array.isArray(meta?.objects) ? meta.objects : []; + return new Set( + objects + .map((o: any) => o?.name) + .filter((n: any): n is string => typeof n === 'string'), + ); + } catch { + return null; + } + }; + let knownObjects = readArtifactObjects(); + const compileAndPing = async () => { if (inFlight) { queued = true; return; } inFlight = true; @@ -419,6 +439,19 @@ export default class Dev extends Command { // available for external trigger sources (cloud webhooks, // git hooks, ad-hoc curl). console.log(chalk.green(` ✓ recompiled in ${dt}ms — server will auto-reload`)); + const objectsNow = readArtifactObjects(); + if (objectsNow) { + const prior = knownObjects; + const fresh = prior ? [...objectsNow].filter((n) => !prior.has(n)) : []; + knownObjects = objectsNow; + if (fresh.length > 0) { + console.log( + chalk.cyan( + ` ✚ new object(s): ${fresh.join(', ')} — table & seeds sync on reload`, + ), + ); + } + } } inFlight = false; if (queued) { queued = false; setTimeout(compileAndPing, 50); } diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 68f8b3a56f..ee7370c7b3 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -71,7 +71,79 @@ function detectCircularDependencies(objects: any[]): string[] { return issues; } -function findOrphanViews(config: any): string[] { +// ── Object-reference walking ──────────────────────────────────────── +// A `config.views` entry is a ViewSchema CONTAINER (`{ list, form, listViews, +// formViews }` — the defineView() shape): the object binding lives on each +// sub-view at `data.object` (provider 'object'), never on a top-level +// `view.object`. Legacy flat ViewItems (top-level `object`) are still read. + +function* subViewsOf(view: any): Generator<[slot: string, subView: any]> { + if (!view || typeof view !== 'object') return; + if (view.list) yield ['list', view.list]; + if (view.form) yield ['form', view.form]; + for (const [key, sub] of Object.entries( + view.listViews && typeof view.listViews === 'object' ? view.listViews : {}, + )) { + yield [`listViews.${key}`, sub]; + } + for (const [key, sub] of Object.entries( + view.formViews && typeof view.formViews === 'object' ? view.formViews : {}, + )) { + yield [`formViews.${key}`, sub]; + } +} + +function subViewObject(sub: any): string | undefined { + if (typeof sub?.data?.object === 'string') return sub.data.object; + if (typeof sub?.objectName === 'string') return sub.objectName; + return undefined; +} + +/** Every object name a view (container or legacy flat item) references. */ +function collectViewObjectRefs(view: any): string[] { + const refs: string[] = []; + if (typeof view?.object === 'string') refs.push(view.object); + for (const [, sub] of subViewsOf(view)) { + const bound = subViewObject(sub); + if (bound) refs.push(bound); + // Inline master-detail children and lookup form fields are references too. + for (const subform of Array.isArray(sub?.subforms) ? sub.subforms : []) { + if (typeof subform?.childObject === 'string') refs.push(subform.childObject); + } + for (const section of Array.isArray(sub?.sections) ? sub.sections : []) { + for (const field of Array.isArray(section?.fields) ? section.fields : []) { + if (typeof field?.reference === 'string') refs.push(field.reference); + } + } + } + return refs; +} + +/** + * Every object name an app's navigation references. Object nav items carry + * `objectName` (AppSchema `ObjectNavItemSchema`), nest under `children`, and + * may live in `areas[*].navigation` instead of the top-level `navigation`. + */ +function collectAppObjectRefs(app: any): string[] { + const refs: string[] = []; + const walk = (items: any): void => { + if (!Array.isArray(items)) return; + for (const item of items) { + if (!item || typeof item !== 'object') continue; + if (typeof item.objectName === 'string') refs.push(item.objectName); + if (typeof item.object === 'string') refs.push(item.object); + if (typeof item.requiresObject === 'string') refs.push(item.requiresObject); + walk(item.children); + } + }; + walk(app?.navigation); + for (const area of Array.isArray(app?.areas) ? app.areas : []) { + walk(area?.navigation); + } + return refs; +} + +export function findOrphanViews(config: any): string[] { const objectNames = new Set(); if (Array.isArray(config.objects)) { for (const obj of config.objects) { @@ -82,15 +154,23 @@ function findOrphanViews(config: any): string[] { const orphans: string[] = []; if (Array.isArray(config.views)) { for (const view of config.views) { - if (view.object && !objectNames.has(view.object)) { + if (typeof view?.object === 'string' && !objectNames.has(view.object)) { orphans.push(`View "${view.name || '?'}" references non-existent object "${view.object}"`); } + for (const [slot, sub] of subViewsOf(view)) { + const bound = subViewObject(sub); + if (bound && !objectNames.has(bound)) { + orphans.push( + `View "${view?.name || sub?.label || slot}" (${slot}) references non-existent object "${bound}"`, + ); + } + } } } return orphans; } -function findUnusedObjects(config: any): string[] { +export function findUnusedObjects(config: any): string[] { const objectNames = new Set(); if (Array.isArray(config.objects)) { for (const obj of config.objects) { @@ -100,38 +180,32 @@ function findUnusedObjects(config: any): string[] { const referencedObjects = new Set(); - // Views reference objects + // Views — container sub-views (data.object), subforms, lookup form fields. if (Array.isArray(config.views)) { for (const view of config.views) { - if (view.object) referencedObjects.add(view.object); + for (const ref of collectViewObjectRefs(view)) referencedObjects.add(ref); } } - // Flows may reference objects via trigger + // Flows — the bound object lives inside node config (FlowNodeSchema.config + // is unstructured; `object`/`objectName` is the canonical alias pair used + // by record_change triggers and CRUD nodes). if (Array.isArray(config.flows)) { for (const flow of config.flows) { if (flow.trigger?.object) referencedObjects.add(flow.trigger.object); if (flow.object) referencedObjects.add(flow.object); + for (const node of Array.isArray(flow?.nodes) ? flow.nodes : []) { + const cfg = node?.config; + if (typeof cfg?.object === 'string') referencedObjects.add(cfg.object); + if (typeof cfg?.objectName === 'string') referencedObjects.add(cfg.objectName); + } } } - // Apps may reference objects via navigation + // Apps — navigation (top-level, nested children, and areas). if (Array.isArray(config.apps)) { for (const app of config.apps) { - if (Array.isArray(app.navigation)) { - for (const nav of app.navigation) { - if (nav.object) referencedObjects.add(nav.object); - } - } - } - } - - // Agents may reference objects - if (Array.isArray(config.agents)) { - for (const agent of config.agents) { - if (Array.isArray(agent.objects)) { - for (const o of agent.objects) referencedObjects.add(o); - } + for (const ref of collectAppObjectRefs(app)) referencedObjects.add(ref); } } @@ -151,7 +225,7 @@ function findUnusedObjects(config: any): string[] { const unused: string[] = []; for (const name of objectNames) { if (!referencedObjects.has(name)) { - unused.push(`Object "${name}" is defined but not referenced by any view, flow, app, or agent`); + unused.push(`Object "${name}" is defined but not referenced by any view, flow, app, or lookup field`); } } return unused; diff --git a/packages/cli/test/doctor-refs.test.ts b/packages/cli/test/doctor-refs.test.ts new file mode 100644 index 0000000000..f275711759 --- /dev/null +++ b/packages/cli/test/doctor-refs.test.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// `os doctor` reference analysis (15.1 third-party eval): the collector only +// read the legacy flat `view.object` / `nav.object` shapes, so an object +// bound through a defineView container (`list.data.object`, `listViews[*]`) +// and hung on app navigation (`objectName`) was still reported as +// "defined but not referenced". These fixtures pin the canonical shapes. + +import { describe, it, expect } from 'vitest'; +import { findUnusedObjects, findOrphanViews } from '../src/commands/doctor'; + +const obj = (name: string, fields: Record = { title: { type: 'text', label: 'T' } }) => ({ + name, + label: name, + fields, +}); + +describe('findUnusedObjects', () => { + it('sees objects bound through defineView containers (list / listViews / form)', () => { + const config = { + objects: [obj('crm_account'), obj('crm_contact'), obj('crm_lead')], + views: [ + { list: { type: 'grid', data: { provider: 'object', object: 'crm_account' } } }, + { + listViews: { + recent: { type: 'grid', data: { provider: 'object', object: 'crm_contact' } }, + }, + }, + { form: { data: { provider: 'object', object: 'crm_lead' } } }, + ], + }; + expect(findUnusedObjects(config)).toEqual([]); + }); + + it('sees app navigation objectName, nested children, and areas', () => { + const config = { + objects: [obj('crm_account'), obj('crm_contact'), obj('crm_case')], + apps: [ + { + name: 'crm', + navigation: [ + { type: 'object', objectName: 'crm_account' }, + { + type: 'group', + label: 'More', + children: [{ type: 'object', objectName: 'crm_contact' }], + }, + ], + areas: [ + { name: 'service', navigation: [{ type: 'object', objectName: 'crm_case' }] }, + ], + }, + ], + }; + expect(findUnusedObjects(config)).toEqual([]); + }); + + it('sees the object inside flow node config (record_change trigger / CRUD nodes)', () => { + const config = { + objects: [obj('crm_order')], + flows: [ + { + name: 'on_order_change', + nodes: [{ id: 't1', type: 'record_change', config: { object: 'crm_order' } }], + }, + ], + }; + expect(findUnusedObjects(config)).toEqual([]); + }); + + it('sees subform childObject and lookup form-field references', () => { + const config = { + objects: [obj('crm_order'), obj('crm_order_line'), obj('crm_account')], + views: [ + { + list: { type: 'grid', data: { provider: 'object', object: 'crm_order' } }, + form: { + data: { provider: 'object', object: 'crm_order' }, + subforms: [{ field: 'lines', childObject: 'crm_order_line' }], + sections: [ + { fields: [{ name: 'account', type: 'lookup', reference: 'crm_account' }] }, + ], + }, + }, + ], + }; + expect(findUnusedObjects(config)).toEqual([]); + }); + + it('still reads legacy flat ViewItems and lookup fields', () => { + const config = { + objects: [ + obj('crm_account'), + obj('crm_contact', { + account: { type: 'lookup', reference: 'crm_account', label: 'Account' }, + }), + ], + views: [{ name: 'contacts', object: 'crm_contact' }], + }; + expect(findUnusedObjects(config)).toEqual([]); + }); + + it('still reports a genuinely unreferenced object', () => { + const config = { + objects: [obj('crm_account'), obj('crm_orphan')], + views: [{ list: { type: 'grid', data: { provider: 'object', object: 'crm_account' } } }], + }; + const unused = findUnusedObjects(config); + expect(unused).toHaveLength(1); + expect(unused[0]).toContain('"crm_orphan"'); + }); +}); + +describe('findOrphanViews', () => { + it('reports container sub-views bound to non-existent objects', () => { + const config = { + objects: [obj('crm_account')], + views: [ + { list: { type: 'grid', data: { provider: 'object', object: 'crm_account' } } }, + { + listViews: { + ghosts: { type: 'grid', data: { provider: 'object', object: 'crm_ghost' } }, + }, + }, + ], + }; + const orphans = findOrphanViews(config); + expect(orphans).toHaveLength(1); + expect(orphans[0]).toContain('"crm_ghost"'); + expect(orphans[0]).toContain('listViews.ghosts'); + }); + + it('passes healthy containers and non-object providers', () => { + const config = { + objects: [obj('crm_account')], + views: [ + { + list: { type: 'grid', data: { provider: 'object', object: 'crm_account' } }, + form: { data: { provider: 'schema', schemaId: 'report' } }, + }, + ], + }; + expect(findOrphanViews(config)).toEqual([]); + }); +}); diff --git a/packages/metadata/src/plugin-hmr-reload.test.ts b/packages/metadata/src/plugin-hmr-reload.test.ts index 82335e24cd..c8525fe217 100644 --- a/packages/metadata/src/plugin-hmr-reload.test.ts +++ b/packages/metadata/src/plugin-hmr-reload.test.ts @@ -33,6 +33,12 @@ function writeArtifact(flowName: string): string { scope: 'app', namespace: 'test', defaultDatasource: 'memory', + // Seeds have no `name`, so they never enter the MetadataManager — + // they reach reload consumers only via the `metadata:reloaded` + // payload (AppPlugin's hot-reload seeder). Pin that pass-through. + data: [ + { object: 'test_note', records: [{ name: 'seeded-row' }] }, + ], flows: [ { name: flowName, @@ -69,9 +75,19 @@ describe('MetadataPlugin._reloadAndAnnounce — fires metadata:reloaded after re const registered = await mgr.get('flow', 'sweep'); expect(registered).toBeDefined(); - // …and the generic reload signal fired with the changed path. + // …and the generic reload signal fired with the changed path AND the + // freshly parsed artifact collections (seeds have no `name`, so the + // payload is the only way they reach reload consumers). expect(ctx.trigger).toHaveBeenCalledTimes(1); - expect(ctx.trigger).toHaveBeenCalledWith('metadata:reloaded', { changed: [file] }); + expect(ctx.trigger).toHaveBeenCalledWith( + 'metadata:reloaded', + expect.objectContaining({ changed: [file] }), + ); + const payload = (ctx.trigger as any).mock.calls[0][1]; + expect(Array.isArray(payload.metadata?.flows)).toBe(true); + expect(payload.metadata?.data).toEqual([ + expect.objectContaining({ object: 'test_note' }), + ]); }); it('still announces even if a subscriber throws (reload must not break)', async () => { diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index 91b1ed7dc5..d03e2c29aa 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -185,6 +185,14 @@ export class MetadataPlugin implements Plugin { private repository?: import('@objectstack/metadata-core').MetadataRepository; /** Chokidar watcher on the artifact file (local-file mode) — ADR-0008 PR-8. */ private artifactWatcher?: { close: () => Promise }; + /** + * The most recently parsed artifact metadata (the plural-field record: + * `objects`, `views`, `data`, …). Carried on the `metadata:reloaded` + * payload so runtime consumers can react to collections that never enter + * the MetadataManager — notably seeds (`data`), whose items have no + * `name` and are skipped by `_parseAndRegisterArtifact`'s register loop. + */ + private lastParsedMetadata?: Record; constructor(options: MetadataPluginOptions = {}) { this.options = { @@ -532,6 +540,8 @@ export class MetadataPlugin implements Plugin { metadata = def as Record; } + this.lastParsedMetadata = metadata; + const memLoader = new MemoryLoader(); const manifestPackageId = (metadata as any)?.manifest?.id ?? (metadata as any)?.id ?? undefined; @@ -634,7 +644,11 @@ export class MetadataPlugin implements Plugin { ): Promise { await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs); try { - await ctx.trigger('metadata:reloaded', { changed }); + // `metadata` carries the freshly parsed artifact collections so + // subscribers can consume the ones that never reach the + // MetadataManager (seeds under `data` have no `name`). AppPlugin + // uses it to load seeds for objects that appear mid-run. + await ctx.trigger('metadata:reloaded', { changed, metadata: this.lastParsedMetadata }); } catch (e: any) { ctx.logger.warn('[MetadataPlugin] metadata:reloaded subscriber failed', { error: e?.message }); } diff --git a/packages/objectql/src/plugin.integration.test.ts b/packages/objectql/src/plugin.integration.test.ts index c97cda2a67..790f66ea41 100644 --- a/packages/objectql/src/plugin.integration.test.ts +++ b/packages/objectql/src/plugin.integration.test.ts @@ -423,6 +423,63 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => { expect(synced.length).toBeGreaterThanOrEqual(2); }); + // 15.1 third-party eval: an object added while the server runs never + // became queryable — `manager.register()` doesn't fire subscribe() + // watchers, so the registry missed it, and tables were only created at + // boot. The `metadata:reloaded` hook must ingest payload objects AND + // re-run the schema sync so a hot-added object is immediately usable. + it('ingests metadata:reloaded payload objects and syncs their tables mid-run', async () => { + const synced: string[] = []; + let probeCtx: any; + const mockDriver = { + name: 'reload-driver', + version: '1.0.0', + connect: async () => {}, + disconnect: async () => {}, + find: async () => [], + findOne: async () => null, + create: async (_o: string, d: any) => d, + update: async (_o: string, _i: any, d: any) => d, + delete: async () => true, + syncSchema: async (object: string) => { + synced.push(object); + }, + }; + + await kernel.use({ + name: 'mock-driver-plugin', + type: 'driver', + version: '1.0.0', + init: async (ctx) => { + probeCtx = ctx; + ctx.registerService('driver.sync', mockDriver); + }, + }); + + const plugin = new ObjectQLPlugin(); + await kernel.use(plugin); + await kernel.bootstrap(); + synced.length = 0; + + // Simulate MetadataPlugin's artifact hot-reload announce. + await probeCtx.trigger('metadata:reloaded', { + changed: ['dist/objectstack.json'], + metadata: { + objects: [ + { + name: 'hotload_widget', + label: 'Widget', + fields: { title: { name: 'title', label: 'Title', type: 'text' } }, + }, + ], + }, + }); + + const ql: any = kernel.getService('objectql'); + expect(ql.registry.getObject('hotload_widget')).toBeTruthy(); + expect(synced).toContain('hotload_widget'); + }); + it('should tolerate drivers without syncSchema', async () => { // Arrange - driver without syncSchema const mockDriver = { diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index e7809a57ca..cc8b1f5fc3 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -131,6 +131,8 @@ export class ObjectQLPlugin implements Plugin { private hostContext?: Record; private environmentId?: string; private skipSchemaSync = false; + /** Serializes reload-time schema syncs so overlapping reloads can't race DDL. */ + private reloadSchemaSync: Promise = Promise.resolve(); private hydrateMetadataFromDb = false; /** Unsubscribe handles for metadata-event subscriptions (ADR-0008 PR-7). */ private metadataUnsubscribes: Array<() => void> = []; @@ -395,9 +397,37 @@ export class ObjectQLPlugin implements Plugin { // No settings service — governance stays at declared defaults. } }); - ctx.hook('metadata:reloaded', async () => { + ctx.hook('metadata:reloaded', async (payload?: unknown) => { await this.resyncAuthoredHooks(ctx); await this.resyncAuthoredActions(ctx); + // 15.1 third-party eval: an object added while `os dev` runs was + // invisible until a manual restart. Two gaps compounded: + // 1. MetadataPlugin's artifact reload calls `manager.register()`, + // which does NOT fire `subscribe()` watchers — so the bridge in + // `subscribeToMetadataEvents` never saw the new object and the + // SchemaRegistry never learned it ("Object … is not registered"). + // Ingest the reloaded object definitions straight off the + // `metadata:reloaded` payload (mirroring the subscribe handler's + // registerObject call, provenance included). + // 2. Tables were only ever created by the boot-time sync — re-run + // the idempotent schema sync after each reload so new objects + // get their DDL immediately. Honors the same opt-out as boot + // (`skipSchemaSync` / OS_SKIP_SCHEMA_SYNC) for deployments that + // manage DDL out-of-band, and serializes through + // `reloadSchemaSync` so overlapping reload events can't race DDL. + this.ingestReloadedObjects(ctx, payload); + if (!this.skipSchemaSync) { + this.reloadSchemaSync = this.reloadSchemaSync.then(async () => { + try { + await this.syncRegisteredSchemas(ctx); + } catch (e: any) { + ctx.logger.warn('[ObjectQLPlugin] reload-time schema sync failed', { + error: e?.message ?? String(e), + }); + } + }); + await this.reloadSchemaSync; + } }); // Discover features from Kernel Services @@ -526,6 +556,50 @@ export class ObjectQLPlugin implements Plugin { this.metadataUnsubscribes = []; } + /** + * Re-register every object definition carried on a `metadata:reloaded` + * payload into the SchemaRegistry. The artifact reload path registers + * items via `MetadataManager.register()`, which does not fire + * `subscribe()` watchers — without this ingest, an object added while the + * server runs never reaches the registry (and therefore can never get a + * table or answer a query). Mirrors `subscribeToMetadataEvents`'s + * registerObject call: provenance comes from the `_packageId` the + * MetadataPlugin stamped during registration (falling back to + * 'metadata-service'), so package attribution stays reload-stable. + * Removed objects are left registered until restart — same lifecycle as + * their tables, which managed drift deliberately never drops. + */ + private ingestReloadedObjects(ctx: PluginContext, payload: unknown): void { + if (!this.ql) return; + const objects = (payload as any)?.metadata?.objects; + if (!Array.isArray(objects) || objects.length === 0) return; + let ingested = 0; + for (const obj of objects) { + const name = (obj as any)?.name; + if (typeof name !== 'string' || name.length === 0) continue; + try { + this.ql.registry.invalidate(name); + this.ql.registry.registerObject( + obj as any, + (obj as any)._packageId ?? 'metadata-service', + (obj as any).namespace, + 'own', + ); + ingested++; + } catch (e: any) { + ctx.logger.warn('[ObjectQLPlugin] reload object ingest failed', { + name, + error: e?.message, + }); + } + } + if (ingested > 0) { + ctx.logger.info('[ObjectQLPlugin] reload ingested object definitions into the registry', { + count: ingested, + }); + } + } + /** * Subscribe to `object` metadata events from the metadata service and * invalidate the SchemaRegistry merge cache on each event (ADR-0008 diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 5d82d08950..86f7b6d1e9 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -2839,3 +2839,44 @@ describe('AuthManager', () => { }); }); }); + +// 15.1 third-party eval — dev-only login hint for the auto-seeded admin. +// `devSeedAdmin` may only ever leave the process in development: the field +// is derived from `devSeedResult`, which the auth plugin sets exclusively +// under NODE_ENV==='development', and getPublicConfig re-checks the env as +// defense in depth. These tests pin the gate from the manager's side. +describe('getPublicConfig devSeedAdmin (dev-only login hint)', () => { + const prevNodeEnv = process.env.NODE_ENV; + afterEach(() => { + process.env.NODE_ENV = prevNodeEnv; + }); + + const makeManager = () => + new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + }); + + it('is absent when devSeedResult is unset', () => { + process.env.NODE_ENV = 'development'; + const manager = makeManager(); + expect((manager.getPublicConfig() as any).devSeedAdmin).toBeUndefined(); + }); + + it('surfaces the seeded credentials in development', () => { + process.env.NODE_ENV = 'development'; + const manager = makeManager(); + manager.devSeedResult = { email: 'admin@objectos.ai', password: 'admin123' }; + expect((manager.getPublicConfig() as any).devSeedAdmin).toEqual({ + email: 'admin@objectos.ai', + password: 'admin123', + }); + }); + + it('never appears outside development even if devSeedResult leaked', () => { + process.env.NODE_ENV = 'production'; + const manager = makeManager(); + manager.devSeedResult = { email: 'admin@objectos.ai', password: 'admin123' }; + expect((manager.getPublicConfig() as any).devSeedAdmin).toBeUndefined(); + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 5fcfb75c55..7472a71108 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -2789,10 +2789,23 @@ export class AuthManager { ...(privacyUrl ? { privacyUrl } : {}), }; + // Dev-only login hint (15.1 third-party eval): the empty-DB auto-seeded + // admin was invisible — nothing on the login page said the account + // exists, so new users signed up into an empty non-admin workspace. + // `devSeedResult` is set at boot by the auth plugin ONLY under + // NODE_ENV==='development' (seeding boot, or later boots while the + // account still verifies against the default password), so this field + // can never appear in production. The env re-check is defense in depth. + const devSeedAdmin = + (globalThis as any)?.process?.env?.NODE_ENV === 'development' && this.devSeedResult + ? { email: this.devSeedResult.email, password: this.devSeedResult.password } + : undefined; + return { emailPassword, socialProviders, features, + ...(devSeedAdmin ? { devSeedAdmin } : {}), }; } diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index bc65c22e9e..8e6e51878a 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -1044,6 +1044,13 @@ export class AuthPlugin implements Plugin { .filter((u: any) => u && u.id !== SystemUserId.SYSTEM && u.role !== 'system'); if (humans.length > 0) { ctx.logger.debug('[auth] dev admin seed skipped — a user already exists'); + // `os dev` defaults to a persistent DB, so the seed fires exactly + // once — but the startup banner and the Console login hint read + // `devSeedResult`, which used to be set only on the seeding boot. + // Re-arm it on every later dev boot for as long as the credentials + // actually still work (account exists AND still carries the default + // password), so returning users keep seeing how to sign in. + await this.maybeReportExistingSeedAdmin(ctx, ql, humans, email, password); return; } @@ -1073,6 +1080,54 @@ export class AuthPlugin implements Plugin { } } + /** + * Boots after the seeding one: surface the dev credentials again if (and + * only if) the seed account still exists with the DEFAULT password — + * verified against the stored credential hash with better-auth's native + * `password.verify`, so the hint disappears the boot after an admin + * changes the password. Only reachable from `maybeSeedDevAdmin`'s + * NODE_ENV==='development' gate; `devSeedResult` therefore never exists in + * production, which is what keeps `getPublicConfig().devSeedAdmin` (the + * login-page hint) dev-only by construction. Best-effort: any lookup or + * verify failure just leaves the hint off. + */ + private async maybeReportExistingSeedAdmin( + ctx: PluginContext, + ql: any, + humans: any[], + email: string, + password: string, + ): Promise { + try { + if (!this.authManager) return; + const seedUser = humans.find( + (u: any) => typeof u?.email === 'string' && u.email.toLowerCase() === email.toLowerCase(), + ); + if (!seedUser?.id) return; + + const accounts = await ql + .find( + 'sys_account', + { where: { user_id: seedUser.id, provider_id: 'credential' }, limit: 1 }, + { context: { isSystem: true } }, + ) + .catch(() => []); + const hash = Array.isArray(accounts) ? accounts[0]?.password : undefined; + if (typeof hash !== 'string' || hash.length === 0) return; + + const authCtx: any = await this.authManager.getAuthContext(); + const verify = authCtx?.password?.verify; + if (typeof verify !== 'function') return; + const stillDefault = await verify.call(authCtx.password, { password, hash }); + if (stillDefault === true) { + this.authManager.devSeedResult = { email, password }; + ctx.logger.debug('[auth] dev admin still on default credentials — surfacing the hint'); + } + } catch (err: any) { + ctx.logger.debug(`[auth] dev seed-admin probe skipped: ${err?.message ?? err}`); + } + } + /** * Register authentication routes with HTTP server * diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index bf03526622..6c3d90d46e 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -893,6 +893,81 @@ export class AppPlugin implements Plugin { } } } + + this.registerHotReloadSeeder(ctx, ql); + } + + /** + * 15.1 third-party eval — dev hot-reload of a NEW object registered its + * metadata (and, via ObjectQL's `metadata:reloaded` hook, created its + * table) but its seeds never ran: the seed pipeline in `start()` only + * reads the boot-time bundle, so `os dev` users had to restart the + * server to get seed rows. Subscribe to `metadata:reloaded` (fired by + * MetadataPlugin for both the artifact-file watcher and the HMR POST + * endpoint; its payload carries the freshly parsed artifact, because + * seeds have no `name` and never enter the MetadataManager) and load the + * seeds of objects that did not exist before the reload. + * + * Scoped STRICTLY to first-seen objects: an already-loaded (and possibly + * user-edited) dataset is never re-upserted mid-run — edits to existing + * seeds still apply on restart, as before. Dev-only (production publish + * flows own their seeding), single-tenant only (multi-tenant replays + * per-org on sys_organization insert). Runs AFTER ObjectQL's reload + * schema sync because kernel hooks fire in registration order and + * ObjectQLPlugin starts first. + */ + private registerHotReloadSeeder(ctx: PluginContext, ql: any): void { + const hook = (ctx as any).hook; + if (typeof hook !== 'function') return; + if (process.env.NODE_ENV !== 'development') return; + if (resolveMultiOrgEnabled()) return; + + const knownObjects = new Set( + (Array.isArray(this.bundle.objects) ? this.bundle.objects : []) + .map((o: any) => o?.name) + .filter((n: any): n is string => typeof n === 'string'), + ); + + hook.call(ctx, 'metadata:reloaded', async (payload: any) => { + try { + const meta = payload?.metadata; + const objectsNow: any[] = Array.isArray(meta?.objects) ? meta.objects : []; + const fresh = objectsNow + .map((o: any) => o?.name) + .filter((n: any): n is string => typeof n === 'string' && !knownObjects.has(n)); + for (const n of fresh) knownObjects.add(n); + if (fresh.length === 0) return; + + const seeds = (Array.isArray(meta?.data) ? meta.data : []).filter( + (d: any) => d?.object && fresh.includes(d.object) && Array.isArray(d.records), + ); + if (seeds.length === 0) return; + + const metadata = ctx.getService('metadata') as IMetadataService | undefined; + if (!metadata) { + ctx.logger.warn('[Seeder] hot-reload seed skipped — metadata service unavailable'); + return; + } + const seedLoader = new SeedLoaderService(ql, metadata, ctx.logger); + const { SeedLoaderRequestSchema } = await import('@objectstack/spec/data'); + const request = SeedLoaderRequestSchema.parse({ + seeds, + config: { defaultMode: 'upsert', multiPass: true }, + }); + const result = await seedLoader.load(request); + const { totalInserted, totalUpdated, totalErrored } = result.summary; + ctx.logger.info( + `[Seeder] Hot-reload seeded new object(s) ${fresh.join(', ')}: ` + + `${totalInserted} inserted, ${totalUpdated} updated` + + (totalErrored ? `, ${totalErrored} errored` : ''), + ); + for (const e of (result.errors ?? []).slice(0, 10)) { + ctx.logger.warn(`[Seeder] ✗ ${e.message}`); + } + } catch (err: any) { + ctx.logger.warn('[Seeder] hot-reload seed failed', { error: err?.message ?? String(err) }); + } + }); } stop = async (ctx: PluginContext) => {