From e8f7f5f30bfcacdb43074b8800988efb0382274f Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:52:09 +0800 Subject: [PATCH 1/3] chore: bump objectui to fdd083657e2d feat(studio): AI-draft review/diff mode in the object designer (v1) (#1456) objectui@fdd083657e2da9832059492d4c88e818a5990a8d --- .objectui-sha | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.objectui-sha b/.objectui-sha index 170033d29..029f0c740 100644 --- a/.objectui-sha +++ b/.objectui-sha @@ -1 +1 @@ -1508a8d32b881335f5820dd47fbb91470c13cbad +fdd083657e2da9832059492d4c88e818a5990a8d From db6897bbb2bd7f327db9a7e4f5e62c058f4eb0e0 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:08:28 +0800 Subject: [PATCH 2/3] fix(dev): unify dev admin seed in-process; remove port-drift HTTP seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm dev:showcase` often failed to provision a usable admin. The CLI seeded over HTTP against a hard-coded localhost:3000, but dev auto-shifts off a busy port — so the POST hit the wrong server (or nothing) and the showcase instance never got an admin. A second, divergent seed in plugin-dev inserted a raw sys_user row with no credential, producing an un-loginable ghost admin (admin@dev.local). Consolidate to a single in-process seed in the runtime: - plugin-auth: maybeSeedDevAdmin() runs on kernel:ready, creates the admin (admin@objectos.ai / admin123) through better-auth's real signUpEmail pipeline (hashed credential + hooks), so it is fully loginable; plugin-security's first-user middleware promotes it to platform admin. Empty-DB only (excludes the SystemUserId.SYSTEM account), idempotent, never overwrites an existing account. Hard-gated to NODE_ENV=development; opt out with OS_SEED_ADMIN=0. No port, no readiness race. - cli/dev: delete the HTTP seedAdminAccount entirely; --seed-admin now just passes OS_SEED_ADMIN[_EMAIL|_PASSWORD] to the serve child. Drop the dead `port` param from the watch loop. - cli/serve: publish the actually-bound port — process.send({type: 'objectstack:listening', port, url}) over IPC + a runtime..json state file under OS_HOME for external supervisors. - plugin-dev: remove the credential-less raw sys_user insert; seedAdminUser now maps to the unified OS_SEED_ADMIN toggle. Verified on the showcase: loginable admin + platform-admin promotion, correct seed under port auto-shift (3999 busy -> 4000), runtime.json written, --no-seed-admin opt-out, and idempotency on a persistent DB (no duplicate user / permission set). Tests: plugin-auth 88, plugin-dev 7, cli 144 — all green. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/dev.ts | 112 +++++------------- packages/cli/src/commands/serve.ts | 30 +++++ .../plugins/plugin-auth/src/auth-plugin.ts | 86 +++++++++++++- packages/plugins/plugin-dev/src/dev-plugin.ts | 49 ++------ 4 files changed, 154 insertions(+), 123 deletions(-) diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 5709ad91b..f91ccc5a4 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -71,7 +71,7 @@ export default class Dev extends Command { default: false, }), 'seed-admin': Flags.boolean({ - description: 'After the server is ready, POST /api/v1/auth/sign-up/email to seed an admin account. Default: on (idempotent — creates the admin only on an empty DB, skips if the email already exists). Disable with --no-seed-admin.', + description: 'Seed a known, loginable dev admin (admin@objectos.ai / admin123) in-process via the runtime on an EMPTY DB, then promote it to platform admin. Default: on (idempotent — only acts on a zero-user DB, never overwrites an existing account). Disable with --no-seed-admin.', allowNo: true, }), 'admin-email': Flags.string({ @@ -173,11 +173,23 @@ export default class Dev extends Command { // flag below already opts the serve command into dev semantics, // and serve.ts will set NODE_ENV='development' internally before // any runtime modules are imported. + // ── Dev admin seeding (in-process) ────────────────────────────── + // Seeding is performed IN-PROCESS by the runtime + // (@objectstack/plugin-auth → maybeSeedDevAdmin) on an empty DB — no + // HTTP POST, no port, no readiness race. The CLI's only job is to pass + // the toggle + credentials through to the serve child via env. + // Default ON in dev; `--no-seed-admin` disables it. The seed is + // idempotent (empty-DB only) and never overwrites an existing account. + const seedAdmin = flags['seed-admin'] ?? true; + const effectiveDb = flags.database ?? freshDbUrl; const localEnv: NodeJS.ProcessEnv = { ...process.env, OS_ENVIRONMENT_ID: environmentId, OS_ARTIFACT_PATH: artifactPath, + OS_SEED_ADMIN: seedAdmin ? '1' : '0', + ...(seedAdmin && flags['admin-email'] ? { OS_SEED_ADMIN_EMAIL: flags['admin-email'] } : {}), + ...(seedAdmin && flags['admin-password'] ? { OS_SEED_ADMIN_PASSWORD: flags['admin-password'] } : {}), ...(freshHome ? { OS_HOME: freshHome } : {}), ...(freshStorageRoot ? { OS_STORAGE_ROOT: freshStorageRoot } : {}), ...(effectiveDb ? { OS_DATABASE_URL: effectiveDb } : {}), @@ -202,30 +214,25 @@ export default class Dev extends Command { ...(flags.verbose ? ['--verbose'] : []), ...(flags.preset ? ['--preset', flags.preset] : []), ], - { stdio: 'inherit', env: localEnv }, + // 'ipc' adds a message channel so the serve child can report the + // port it ACTUALLY bound (dev auto-shifts off a busy port). Without + // this, the parent only knows the requested port. + { stdio: ['inherit', 'inherit', 'inherit', 'ipc'], env: localEnv }, ); - // ── Seed admin account after the server is ready ──────────────── - // `--seed-admin` defaults to ON in dev. The seed is idempotent and - // NON-DESTRUCTIVE: it POSTs the public sign-up endpoint, which creates - // `admin@objectos.ai` only on an EMPTY DB (then `bootstrapPlatformAdmin` - // in @objectstack/plugin-security promotes that first user to platform - // admin). When the email already exists — i.e. a persistent dev DB you - // signed up against before — better-auth returns 422/400 and we skip - // (see the `r.status === 422 || 400` branch below), so a custom password - // is never overwritten. There's therefore no need to gate on ephemeral - // vs persistent: seeding an empty DB is exactly what you want, and a - // populated DB is left untouched. - // The dev-mode bootstrap bypass (auth-manager.ts) lets the very first - // sign-up through even when OS_DISABLE_SIGNUP=true. - const seedAdmin = flags['seed-admin'] ?? true; - if (seedAdmin) { - void this.seedAdminAccount({ - port: port ?? '3000', - email: flags['admin-email'], - password: flags['admin-password'], - }); - } + // ── Learn the actually-bound port from the serve child ────────── + // The child emits `{ type: 'objectstack:listening', port, url }` once + // its HTTP server is up. We surface it so the printed URL is correct + // even when the port was auto-shifted (e.g. 3000 busy → 3001). + const requestedPort = port ?? '3000'; + serveChild.on('message', (msg: any) => { + if (msg?.type === 'objectstack:listening' && msg.port) { + const actual = String(msg.port); + if (actual !== requestedPort) { + console.log(chalk.dim(` ↪ server bound to port ${actual} (requested ${requestedPort})`)); + } + } + }); // ── Watch-recompile loop ──────────────────────────────────────── // When the agent edits an objectstack source file (config or @@ -244,7 +251,6 @@ export default class Dev extends Command { configPath, artifactPath, binPath, - port: port ?? '3000', verbose: flags.verbose, }); } @@ -300,7 +306,6 @@ export default class Dev extends Command { configPath: string; artifactPath: string; binPath: string; - port: string; verbose?: boolean; }): void { void (async () => { @@ -388,63 +393,6 @@ export default class Dev extends Command { console.error(chalk.yellow(` ⚠ watch-recompile failed to start: ${e?.message ?? e}`)); }); } - - /** - * Poll `/api/v1/health` until 200, then POST `/api/v1/auth/sign-up/email` - * to provision an admin account. Best-effort: any failure is logged - * but does not bring down the dev server. - * - * Pairs naturally with `--fresh`: an ephemeral DB has zero users, so - * the first sign-up is automatically the platform admin (promoted by - * `bootstrapPlatformAdmin` in @objectstack/plugin-security). - */ - private async seedAdminAccount(opts: { - port: string; - email: string; - password: string; - }): Promise { - const base = `http://localhost:${opts.port}`; - const deadline = Date.now() + 30_000; - - while (Date.now() < deadline) { - try { - const r = await fetch(`${base}/api/v1/health`); - if (r.ok) break; - } catch { /* not ready */ } - await new Promise(r => setTimeout(r, 250)); - } - - try { - const r = await fetch(`${base}/api/v1/auth/sign-up/email`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - // better-auth enforces a same-origin / trusted-origin check on - // POST. localhost is auto-trusted in dev (auth-manager.ts), but - // we still must send an Origin header for the check to fire. - 'origin': base, - }, - body: JSON.stringify({ - email: opts.email, - password: opts.password, - name: 'Dev Admin', - }), - }); - if (r.ok) { - console.log(chalk.green( - `\n🔑 Admin seeded: ${chalk.bold(opts.email)} / ${chalk.bold(opts.password)}`, - )); - } else if (r.status === 422 || r.status === 400) { - // User already exists — normal when reusing a non-fresh DB. - console.log(chalk.dim(` (admin ${opts.email} already exists — skipping seed)`)); - } else { - const body = await r.text().catch(() => ''); - console.log(chalk.yellow(` ⚠ seed-admin failed (HTTP ${r.status}): ${body.slice(0, 200)}`)); - } - } catch (e: any) { - console.log(chalk.yellow(` ⚠ seed-admin request failed: ${e?.message ?? e}`)); - } - } } function redactDbUrl(url: string): string { diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index fef6ac747..2a9bfca53 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -9,6 +9,7 @@ import { bundleRequire } from 'bundle-require'; import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js'; import { readEnvWithDeprecation } from '@objectstack/types'; +import { resolveObjectStackHome } from '@objectstack/runtime'; import { printHeader, printKV, @@ -1767,6 +1768,35 @@ export default class Serve extends Command { multiTenant: String(readEnvWithDeprecation('OS_MULTI_ORG_ENABLED', 'OS_MULTI_TENANT') ?? 'false').toLowerCase() !== 'false', }); + // ── Publish the actually-bound port ──────────────────────────── + // `port` here is the port the HTTP server actually bound — already + // resolved past any dev auto-shift (busy 3000 → 3001). Publish it so + // supervisors and the `os dev` parent never have to guess: + // • IPC: when spawned with an 'ipc' channel (as `os dev` does), the + // parent learns the real port without polling. + // • runtime.json: a small state file under OS_HOME for external + // supervisors / health checks (pid + port + url). + const runtimeUrl = `http://localhost:${port}`; + try { + if (typeof process.send === 'function') { + process.send({ type: 'objectstack:listening', port: Number(port), url: runtimeUrl }); + } + } catch { /* IPC channel closed — best-effort */ } + try { + const environmentId = process.env.OS_ENVIRONMENT_ID ?? 'env_local'; + const runtimeFile = path.join(resolveObjectStackHome(), `runtime.${environmentId}.json`); + fs.mkdirSync(path.dirname(runtimeFile), { recursive: true }); + fs.writeFileSync(runtimeFile, JSON.stringify({ + pid: process.pid, + port: Number(port), + url: runtimeUrl, + environmentId, + startedAt: new Date().toISOString(), + }, null, 2)); + const cleanupRuntimeFile = () => { try { fs.rmSync(runtimeFile, { force: true }); } catch { /* noop */ } }; + process.on('exit', cleanupRuntimeFile); + } catch { /* non-fatal — supervision file is best-effort */ } + // Kernel already registers SIGINT/SIGTERM handlers during bootstrap. // No duplicate handler needed here — just keep the process alive. diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 5c4511606..5ba28d659 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -2,7 +2,7 @@ import { Plugin, PluginContext, IHttpServer } from '@objectstack/core'; import type { BetterAuthOptions } from 'better-auth'; -import { AuthConfig } from '@objectstack/spec/system'; +import { AuthConfig, SystemObjectName, SystemUserId } from '@objectstack/spec/system'; import { SETUP_APP, SETUP_NAV_CONTRIBUTIONS, @@ -286,6 +286,13 @@ export class AuthPlugin implements Plugin { }); } + // Dev-only: provision a known, loginable platform admin on an empty DB. + // Registered as its own kernel:ready hook (independent of registerRoutes) + // so it runs whenever the runtime boots in development. + ctx.hook('kernel:ready', async () => { + await this.maybeSeedDevAdmin(ctx); + }); + // Register auth middleware on ObjectQL engine (if available) try { const ql = ctx.getService('objectql'); @@ -312,6 +319,83 @@ export class AuthPlugin implements Plugin { this.authManager = null; } + /** + * Dev-only admin bootstrap. + * + * On an EMPTY database (zero users), provision a well-known, loginable + * admin (admin@objectos.ai / admin123 by default) so backend debugging + * never blocks on a first-run sign-up wizard. The account is created + * through better-auth's real server-side `signUpEmail` pipeline (hashed + * credential + the same hooks the HTTP endpoint runs), so it is fully + * loginable; plugin-security's first-user middleware then promotes it to + * platform admin automatically. + * + * This replaces two earlier, divergent seeds: + * • the CLI-side HTTP seed (`os dev`), which POSTed the public sign-up + * endpoint from the parent process — racing server readiness and + * targeting a hard-coded port that broke under dev port auto-shift; and + * • plugin-dev's raw `sys_user` insert, which produced a credential-less, + * un-loginable row. + * Running it in-process needs no port and no readiness polling. + * + * Idempotent and non-destructive: it only ever acts on a zero-user DB and + * never touches an existing account, so a custom password is never + * overwritten. + * + * HARD-GATED to development (NODE_ENV==='development'): a known-credential + * admin can never be provisioned in production. Opt out within dev via + * OS_SEED_ADMIN=0 (or false/off/no). + */ + private async maybeSeedDevAdmin(ctx: PluginContext): Promise { + if (process.env.NODE_ENV !== 'development') return; + const flag = String(process.env.OS_SEED_ADMIN ?? '').trim().toLowerCase(); + if (['0', 'false', 'off', 'no'].includes(flag)) return; + + const email = process.env.OS_SEED_ADMIN_EMAIL?.trim() || 'admin@objectos.ai'; + const password = process.env.OS_SEED_ADMIN_PASSWORD?.trim() || 'admin123'; + const name = process.env.OS_SEED_ADMIN_NAME?.trim() || 'Dev Admin'; + + let ql: any; + try { ql = ctx.getService('objectql'); } catch { /* unavailable */ } + if (!ql || typeof ql.find !== 'function') return; + + try { + // Only seed when no HUMAN user exists yet. A fresh DB still contains + // the system service account (SystemUserId.SYSTEM, role='system'), + // which must NOT count — mirror plugin-security's first-user detection + // so the seed fires on a genuinely empty DB. Any real human user (or a + // prior sign-up) disables the seed for good; we never touch or + // overwrite an existing account. + const rows = await ql + .find(SystemObjectName.USER, { where: {}, limit: 50 }, { context: { isSystem: true } }) + .catch(() => []); + const humans = (Array.isArray(rows) ? rows : []) + .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'); + return; + } + + if (!this.authManager) return; + const api: any = await this.authManager.getApi(); + if (typeof api?.signUpEmail !== 'function') { + ctx.logger.warn('[auth] dev admin seed skipped — signUpEmail unavailable'); + return; + } + + // Real auth pipeline: creates sys_user + a hashed `credential` account + // and runs the sign-up hooks. The dev-mode OS_DISABLE_SIGNUP bypass + // (auth-manager.ts) lets this through on an empty DB even when sign-up + // is otherwise disabled. + await api.signUpEmail({ body: { email, password, name } }); + ctx.logger.info(`🔑 Dev admin seeded: ${email} / ${password}`); + } catch (err: any) { + // Best-effort. The common benign case is a race where a real sign-up + // landed first (unique-email violation) — treat as "already seeded". + ctx.logger.warn(`[auth] dev admin seed skipped: ${err?.message ?? err}`); + } + } + /** * Register authentication routes with HTTP server * diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index 9dbe1b3c9..e6a2b12d5 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -2,7 +2,6 @@ import { Plugin, PluginContext, createMemoryCache, createMemoryQueue, createMemoryJob, createMemoryI18n } from '@objectstack/core'; import { readEnvWithDeprecation } from '@objectstack/types'; -import { SystemObjectName } from '@objectstack/spec/system'; /** * All 17 core kernel service names as defined in CoreServiceName. @@ -686,9 +685,15 @@ export class DevPlugin implements Plugin { } } - // Seed default admin user - if (this.options.seedAdminUser) { - await this.seedAdmin(ctx); + // Dev admin seeding is now centralised in the runtime + // (@objectstack/plugin-auth → maybeSeedDevAdmin), which provisions a + // REAL, loginable platform admin via better-auth's signUpEmail pipeline. + // The previous raw `sys_user` insert here produced a credential-less, + // un-loginable row and has been removed. We only translate this plugin's + // `seedAdminUser` option into the OS_SEED_ADMIN toggle the runtime reads, + // without clobbering an explicit env value the operator already set. + if (process.env.OS_SEED_ADMIN == null) { + process.env.OS_SEED_ADMIN = this.options.seedAdminUser ? '1' : '0'; } ctx.logger.info('─────────────────────────────────────────'); @@ -718,40 +723,4 @@ export class DevPlugin implements Plugin { } } - /** - * Seed a default admin user for development. - */ - private async seedAdmin(ctx: PluginContext): Promise { - try { - const dataEngine = ctx.getService('data'); - if (!dataEngine) return; - - // Check if admin already exists - const existing = await dataEngine.find(SystemObjectName.USER, { - filter: { email: 'admin@dev.local' }, - limit: 1, - }).catch(() => null); - - if (existing?.length) { - ctx.logger.debug('Dev admin user already exists'); - return; - } - - await dataEngine.insert(SystemObjectName.USER, { - data: { - name: 'Admin', - email: 'admin@dev.local', - username: 'admin', - role: 'admin', - }, - }).catch(() => { - // Table might not exist yet — that's fine for dev - }); - - ctx.logger.info('🔑 Dev admin user seeded: admin@dev.local'); - } catch { - // Non-fatal — user seeding is best-effort - ctx.logger.debug('Could not seed admin user (data engine may not be ready)'); - } - } } From 7ef99b2341a0b6c4d2bd40310ef67918ae177c44 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:10:16 +0800 Subject: [PATCH 3/3] chore: add changeset for dev seed-admin fix Co-Authored-By: Claude Opus 4.8 --- .changeset/fix-dev-seed-admin-in-process.md | 29 +++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .changeset/fix-dev-seed-admin-in-process.md diff --git a/.changeset/fix-dev-seed-admin-in-process.md b/.changeset/fix-dev-seed-admin-in-process.md new file mode 100644 index 000000000..8ffc875e3 --- /dev/null +++ b/.changeset/fix-dev-seed-admin-in-process.md @@ -0,0 +1,29 @@ +--- +"@objectstack/plugin-auth": patch +"@objectstack/cli": patch +"@objectstack/plugin-dev": patch +--- + +fix(dev): seed the dev admin in-process and fix the port-drift seed failure. + +`os dev` (and `pnpm dev:showcase`) seeded the admin over HTTP against a +hard-coded `localhost:3000`. In dev, `serve` auto-shifts off a busy port, so +the seed POST hit the wrong server (or nothing) and the running instance never +got an admin. A second, divergent seed in `plugin-dev` inserted a +credential-less `sys_user` row that could not log in. + +Consolidate to a single in-process seed: + +- **`@objectstack/plugin-auth`** — `maybeSeedDevAdmin()` runs on `kernel:ready` + and creates `admin@objectos.ai` / `admin123` through better-auth's real + `signUpEmail` pipeline (hashed credential), so the account is loginable; + `plugin-security` then promotes it to platform admin. Empty-DB only + (excludes the system service account), idempotent, never overwrites an + existing account. Hard-gated to `NODE_ENV=development`; opt out with + `OS_SEED_ADMIN=0`. +- **`@objectstack/cli`** — removed the HTTP seed; `--seed-admin` now passes + `OS_SEED_ADMIN[_EMAIL|_PASSWORD]` to the serve child. `serve` publishes its + actually-bound port over IPC and to a `runtime..json` state file under + `OS_HOME`. +- **`@objectstack/plugin-dev`** — removed the credential-less raw insert; + `seedAdminUser` maps to the unified `OS_SEED_ADMIN` toggle.