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
29 changes: 29 additions & 0 deletions .changeset/fix-dev-seed-admin-in-process.md
Original file line number Diff line number Diff line change
@@ -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.<env>.json` state file under
`OS_HOME`.
- **`@objectstack/plugin-dev`** — removed the credential-less raw insert;
`seedAdminUser` maps to the unified `OS_SEED_ADMIN` toggle.
2 changes: 1 addition & 1 deletion .objectui-sha
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1508a8d32b881335f5820dd47fbb91470c13cbad
fdd083657e2da9832059492d4c88e818a5990a8d
112 changes: 30 additions & 82 deletions packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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 } : {}),
Expand All @@ -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
Expand All @@ -244,7 +251,6 @@ export default class Dev extends Command {
configPath,
artifactPath,
binPath,
port: port ?? '3000',
verbose: flags.verbose,
});
}
Expand Down Expand Up @@ -300,7 +306,6 @@ export default class Dev extends Command {
configPath: string;
artifactPath: string;
binPath: string;
port: string;
verbose?: boolean;
}): void {
void (async () => {
Expand Down Expand Up @@ -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<void> {
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 {
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand Down
86 changes: 85 additions & 1 deletion packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<any>('objectql');
Expand All @@ -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<void> {
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<any>('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
*
Expand Down
Loading