@objectstack/cli 4.0.5 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +32 -0
  2. package/dist/commands/cloud/login.d.ts +16 -0
  3. package/dist/commands/cloud/login.d.ts.map +1 -0
  4. package/dist/commands/cloud/login.js +166 -0
  5. package/dist/commands/cloud/login.js.map +1 -0
  6. package/dist/commands/cloud/logout.d.ts +15 -0
  7. package/dist/commands/cloud/logout.d.ts.map +1 -0
  8. package/dist/commands/cloud/logout.js +51 -0
  9. package/dist/commands/cloud/logout.js.map +1 -0
  10. package/dist/commands/cloud/whoami.d.ts +15 -0
  11. package/dist/commands/cloud/whoami.d.ts.map +1 -0
  12. package/dist/commands/cloud/whoami.js +81 -0
  13. package/dist/commands/cloud/whoami.js.map +1 -0
  14. package/dist/commands/dev.d.ts +6 -0
  15. package/dist/commands/dev.d.ts.map +1 -1
  16. package/dist/commands/dev.js +79 -14
  17. package/dist/commands/dev.js.map +1 -1
  18. package/dist/commands/login.js +2 -2
  19. package/dist/commands/login.js.map +1 -1
  20. package/dist/commands/package/publish.d.ts +32 -0
  21. package/dist/commands/package/publish.d.ts.map +1 -0
  22. package/dist/commands/package/publish.js +324 -0
  23. package/dist/commands/package/publish.js.map +1 -0
  24. package/dist/commands/publish.d.ts +3 -0
  25. package/dist/commands/publish.d.ts.map +1 -1
  26. package/dist/commands/publish.js +55 -11
  27. package/dist/commands/publish.js.map +1 -1
  28. package/dist/commands/rollback.d.ts +13 -0
  29. package/dist/commands/rollback.d.ts.map +1 -0
  30. package/dist/commands/rollback.js +77 -0
  31. package/dist/commands/rollback.js.map +1 -0
  32. package/dist/commands/serve.d.ts +14 -0
  33. package/dist/commands/serve.d.ts.map +1 -1
  34. package/dist/commands/serve.js +583 -30
  35. package/dist/commands/serve.js.map +1 -1
  36. package/dist/commands/start.d.ts +8 -1
  37. package/dist/commands/start.d.ts.map +1 -1
  38. package/dist/commands/start.js +80 -11
  39. package/dist/commands/start.js.map +1 -1
  40. package/dist/index.d.ts +4 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +6 -0
  43. package/dist/index.js.map +1 -1
  44. package/dist/utils/auth-flows.d.ts +31 -0
  45. package/dist/utils/auth-flows.d.ts.map +1 -0
  46. package/dist/utils/auth-flows.js +151 -0
  47. package/dist/utils/auth-flows.js.map +1 -0
  48. package/dist/utils/cloud-config.d.ts +24 -0
  49. package/dist/utils/cloud-config.d.ts.map +1 -0
  50. package/dist/utils/cloud-config.js +75 -0
  51. package/dist/utils/cloud-config.js.map +1 -0
  52. package/dist/utils/console.d.ts +1 -0
  53. package/dist/utils/console.d.ts.map +1 -1
  54. package/dist/utils/console.js +7 -4
  55. package/dist/utils/console.js.map +1 -1
  56. package/dist/utils/studio.d.ts.map +1 -1
  57. package/dist/utils/studio.js +5 -6
  58. package/dist/utils/studio.js.map +1 -1
  59. package/package.json +40 -36
@@ -35,7 +35,7 @@ const getAvailablePort = async (startPort) => {
35
35
  return port;
36
36
  };
37
37
  export default class Serve extends Command {
38
- static description = 'Start ObjectStack server with plugins from configuration';
38
+ static description = 'Start ObjectStack server. Reads `objectstack.config.ts` if present; otherwise falls back to `dist/objectstack.json` (or OS_ARTIFACT_PATH, including http(s):// URLs) as a portable artifact.';
39
39
  static args = {
40
40
  config: Args.string({ description: 'Configuration file path', required: false, default: 'objectstack.config.ts' }),
41
41
  };
@@ -43,6 +43,11 @@ export default class Serve extends Command {
43
43
  port: Flags.string({ char: 'p', description: 'Server port', default: process.env.PORT ?? '3000' }),
44
44
  dev: Flags.boolean({ description: 'Run in development mode (load devPlugins)' }),
45
45
  ui: Flags.boolean({ description: 'Enable Studio UI at /_studio/ (default: true)', default: true, allowNo: true }),
46
+ console: Flags.boolean({
47
+ description: 'Mount the Console UI at /_console/ when the package is installed (default: true). When disabled, Studio claims the root redirect.',
48
+ default: true,
49
+ allowNo: true,
50
+ }),
46
51
  server: Flags.boolean({ description: 'Start HTTP server plugin', default: true, allowNo: true }),
47
52
  prebuilt: Flags.boolean({ description: 'Skip esbuild/bundle-require — load config as native ESM (production mode)', default: false }),
48
53
  preset: Flags.string({
@@ -50,6 +55,21 @@ export default class Serve extends Command {
50
55
  options: ['minimal', 'default', 'full'],
51
56
  }),
52
57
  };
58
+ /**
59
+ * Capabilities auto-added to every app's `requires` for every preset
60
+ * EXCEPT `minimal`. These form the foundation that every server-side
61
+ * runtime expects to exist (background work, settings persistence,
62
+ * transactional mail, file uploads). Apps may still list these in
63
+ * `requires:` explicitly — duplicates are de-duped.
64
+ *
65
+ * Opt out: `objectstack serve --preset minimal`.
66
+ *
67
+ * Mirrored on hosted objectos per-project kernels by
68
+ * `mountDefaultProjectPlugins()` in `@objectstack/service-cloud`.
69
+ */
70
+ static ALWAYS_ON_CAPABILITIES = Object.freeze([
71
+ 'queue', 'job', 'cache', 'settings', 'email', 'storage',
72
+ ]);
53
73
  /**
54
74
  * Auto-registered plugin tiers. Plugins explicitly listed in
55
75
  * `config.plugins` are always loaded — tiers only gate the optional
@@ -62,6 +82,14 @@ export default class Serve extends Command {
62
82
  };
63
83
  async run() {
64
84
  const { args, flags } = await this.parse(Serve);
85
+ // When --dev is passed, set NODE_ENV early so any runtime modules
86
+ // imported below (and any deps that branch on NODE_ENV at import
87
+ // time) see development mode. We deliberately do NOT inherit
88
+ // NODE_ENV from the parent `os dev` spawn — see the note in
89
+ // commands/dev.ts for why.
90
+ if (flags.dev && !process.env.NODE_ENV) {
91
+ process.env.NODE_ENV = 'development';
92
+ }
65
93
  let port = parseInt(flags.port);
66
94
  try {
67
95
  const availablePort = await getAvailablePort(port);
@@ -80,14 +108,36 @@ export default class Serve extends Command {
80
108
  const isDev = flags.dev || process.env.NODE_ENV === 'development';
81
109
  const absolutePath = path.resolve(process.cwd(), args.config);
82
110
  const relativeConfig = path.relative(process.cwd(), absolutePath);
83
- if (!fs.existsSync(absolutePath)) {
84
- printError(`Configuration file not found: ${absolutePath}`);
85
- console.log(chalk.dim(' Hint: Run `objectstack init` to create a new project'));
86
- this.exit(1);
111
+ // ── Artifact-first fallback ──────────────────────────────────────
112
+ // If the user did not author an `objectstack.config.ts`, but a
113
+ // compiled artifact is reachable (explicit OS_ARTIFACT_PATH
114
+ // including http(s):// URLs — or the canonical
115
+ // `<cwd>/dist/objectstack.json`), boot from that artifact alone.
116
+ // This is the same capability previously hard-coded in
117
+ // `apps/objectos/objectstack.config.ts`, lifted into the framework
118
+ // so any project can `objectstack start` against just a
119
+ // `dist/objectstack.json`.
120
+ const configMissing = !fs.existsSync(absolutePath);
121
+ let useArtifactFallback = false;
122
+ if (configMissing) {
123
+ const { resolveDefaultArtifactPath } = await import('@objectstack/runtime');
124
+ const artifactSource = resolveDefaultArtifactPath();
125
+ if (!artifactSource) {
126
+ printError(`Configuration file not found: ${absolutePath}`);
127
+ console.log(chalk.dim(' Hint: Run `objectstack init` to create a new project,'));
128
+ console.log(chalk.dim(' or run `objectstack build` first, or set OS_ARTIFACT_PATH.'));
129
+ this.exit(1);
130
+ }
131
+ useArtifactFallback = true;
87
132
  }
88
133
  // Quiet loading — only show a single spinner line
89
134
  console.log('');
90
- console.log(chalk.dim(` Loading ${relativeConfig}...`));
135
+ if (useArtifactFallback) {
136
+ console.log(chalk.dim(' No objectstack.config.ts found — booting from artifact (default host)...'));
137
+ }
138
+ else {
139
+ console.log(chalk.dim(` Loading ${relativeConfig}...`));
140
+ }
91
141
  // Track loaded plugins for summary
92
142
  const loadedPlugins = [];
93
143
  const shortPluginName = (raw) => {
@@ -149,11 +199,15 @@ export default class Serve extends Command {
149
199
  // Load configuration
150
200
  // --prebuilt: load as native ESM (no esbuild, no bundle-require) —
151
201
  // intended for production where the config has been compiled to dist/.
152
- const { mod } = flags.prebuilt
153
- ? { mod: await import(absolutePath.startsWith('/') ? `file://${absolutePath}` : absolutePath) }
154
- : await bundleRequire({ filepath: absolutePath });
202
+ // --artifact-fallback: skip config loading entirely; the default-host
203
+ // helper will synthesize a stack from the artifact JSON below.
204
+ const { mod } = useArtifactFallback
205
+ ? { mod: { default: {} } }
206
+ : flags.prebuilt
207
+ ? { mod: await import(absolutePath.startsWith('/') ? `file://${absolutePath}` : absolutePath) }
208
+ : await bundleRequire({ filepath: absolutePath });
155
209
  let config = mod.default || mod;
156
- if (!config) {
210
+ if (!useArtifactFallback && !config) {
157
211
  throw new Error(`No default export found in ${args.config}`);
158
212
  }
159
213
  // Preserve module-level named exports (e.g. `onEnable`, `onDisable`
@@ -173,19 +227,39 @@ export default class Serve extends Command {
173
227
  // Boot-mode dispatch: standalone goes directly through
174
228
  // `@objectstack/runtime` (no cloud dependencies). runtime/cloud
175
229
  // modes go through `@objectstack/service-cloud`.
176
- if (shouldBootWithLibrary(config)) {
230
+ if (useArtifactFallback || shouldBootWithLibrary(config)) {
177
231
  // The boot stack returns only `{plugins, api}` — preserve the
178
232
  // original stack metadata (notably `requires`, `analyticsCubes`,
179
233
  // `tiers`) so the capability resolver further down can read it.
180
234
  const originalConfig = config;
181
235
  const resolvedMode = config.bootMode ?? process.env.OS_MODE ?? 'standalone';
182
- if (resolvedMode === 'standalone') {
236
+ if (useArtifactFallback) {
237
+ // Artifact-only boot — no objectstack.config.ts authored.
238
+ // Always use the default-host helper which is standalone-only
239
+ // and never depends on @objectstack/service-cloud.
240
+ const { createDefaultHostConfig } = await import('@objectstack/runtime');
241
+ const bootResult = await createDefaultHostConfig();
242
+ config = { ...originalConfig, ...bootResult };
243
+ }
244
+ else if (resolvedMode === 'standalone') {
183
245
  const { createStandaloneStack } = await import('@objectstack/runtime');
184
246
  const bootResult = await createStandaloneStack(config.standalone);
185
247
  config = { ...originalConfig, ...bootResult };
186
248
  }
187
249
  else {
188
- const { createBootStack } = await import('@objectstack/service-cloud');
250
+ // Cloud / multi-project boot modes require @objectstack/service-cloud.
251
+ // When the package is unavailable (e.g. someone vendored only the
252
+ // public framework), fail with a clear, actionable error instead of
253
+ // an opaque module-not-found stack trace.
254
+ let createBootStack;
255
+ try {
256
+ ({ createBootStack } = await import('@objectstack/service-cloud'));
257
+ }
258
+ catch (err) {
259
+ throw new Error(`Boot mode '${resolvedMode}' requires @objectstack/service-cloud, which is not installed.\n`
260
+ + `Either install it (\`pnpm add @objectstack/service-cloud\`) or switch to bootMode='standalone'.\n`
261
+ + `Underlying error: ${err?.message ?? String(err)}`);
262
+ }
189
263
  const bootResult = await createBootStack({
190
264
  mode: config.bootMode,
191
265
  runtime: config.runtime ?? config.project,
@@ -210,6 +284,38 @@ export default class Serve extends Command {
210
284
  const requires = Array.isArray(config.requires)
211
285
  ? config.requires.filter((c) => typeof c === 'string')
212
286
  : [];
287
+ // Auth callbacks (password-reset, email-verification, magic-link,
288
+ // invitation) depend on the email service. Auto-pull `email` when
289
+ // `auth` is required so transactional mail works out of the box
290
+ // (LogTransport fallback when no provider is configured).
291
+ if (requires.includes('auth') && !requires.includes('email')) {
292
+ requires.push('email');
293
+ }
294
+ // Default capability slate — every preset except `minimal` gets the
295
+ // foundational services (queue + job + cache + settings + email +
296
+ // storage). Opt out with `objectstack serve --preset minimal`.
297
+ // Keeping `auth → email` above as a defensive rule for users who
298
+ // explicitly opt into `minimal` but still enable auth.
299
+ const ALWAYS_CAPS = Serve.ALWAYS_ON_CAPABILITIES;
300
+ if (presetName !== 'minimal') {
301
+ for (const cap of ALWAYS_CAPS) {
302
+ if (!requires.includes(cap))
303
+ requires.push(cap);
304
+ }
305
+ }
306
+ // The email + approvals + reports services schedule background work
307
+ // (durable retries, SLA escalation, scheduled digests). Auto-pull
308
+ // 'job' and 'queue' so plugins can opt into durable scheduling.
309
+ // IMPORTANT: prepend, so their plugins load (and their kernel:ready
310
+ // hooks fire) BEFORE consumers like email/approvals that subscribe
311
+ // to queues during their own kernel:ready phase.
312
+ const NEEDS_JOB_OR_QUEUE = ['email', 'approvals', 'reports', 'auth'];
313
+ if (NEEDS_JOB_OR_QUEUE.some((c) => requires.includes(c))) {
314
+ if (!requires.includes('queue'))
315
+ requires.unshift('queue');
316
+ if (!requires.includes('job'))
317
+ requires.unshift('job');
318
+ }
213
319
  // Capability → tier: any capability that is gated by a tier
214
320
  // here automatically opens that tier when listed in `requires`.
215
321
  // Capabilities NOT in this map (e.g. `automation`, `analytics`,
@@ -447,6 +553,193 @@ export default class Serve extends Command {
447
553
  console.warn(chalk.yellow(` ⚠ HTTP server plugin not available: ${e.message}`));
448
554
  }
449
555
  }
556
+ // Unknown-environment hostname guard.
557
+ //
558
+ // In multi-tenant cloud deployments (e.g. *.objectos.app), every
559
+ // public hostname is expected to map to a `sys_environment` row
560
+ // whose `hostname` column matches the request `Host`. Without this
561
+ // guard, an unknown subdomain like `demo-xxx.objectos.app` happily
562
+ // renders the control-plane Console SPA (served statically by
563
+ // createConsoleStaticPlugin), making the deployment look like an
564
+ // empty env rather than a missing one. We respond with a clear
565
+ // 404 instead.
566
+ //
567
+ // Activation: only when OS_ROOT_DOMAIN is set (e.g. "objectos.app").
568
+ // Reserved subdomains (cloud/www/api/docs/admin/app and the apex)
569
+ // bypass the check so platform surfaces keep working. Non-root
570
+ // hostnames (custom domains, localhost, *.workers.dev) pass through
571
+ // unchanged. Infra paths under /_admin or /.well-known are always
572
+ // allowed so health checks / cert flows aren't broken.
573
+ //
574
+ // Implemented as a Plugin so the middleware is wired during init
575
+ // (when http.server is available) and BEFORE start() runs on the
576
+ // Console static plugin / route-registering plugins. Hono's
577
+ // `app.use('*')` is order-independent for matching, so as long as
578
+ // the middleware is added before kernel:listening fires, it
579
+ // intercepts every request regardless of which plugin registered
580
+ // its handler.
581
+ const __rootDomain = (process.env.OS_ROOT_DOMAIN || '').trim().toLowerCase();
582
+ if (__rootDomain) {
583
+ const RESERVED = new Set(['', 'cloud', 'www', 'api', 'docs', 'admin', 'app']);
584
+ const guardPlugin = {
585
+ name: 'com.objectstack.cli.unknown-hostname-guard',
586
+ version: '1.0.0',
587
+ init: async (ctx) => {
588
+ try {
589
+ const httpServer = ctx.getService?.('http.server') ?? ctx.getService?.('http-server');
590
+ const rawApp = httpServer?.getRawApp?.();
591
+ if (!rawApp || typeof rawApp.use !== 'function') {
592
+ ctx.logger?.warn?.('[unknown-hostname-guard] http.server unavailable; guard not installed');
593
+ return;
594
+ }
595
+ const getEnvRegistry = () => {
596
+ try {
597
+ return ctx.getService?.('env-registry') ?? null;
598
+ }
599
+ catch {
600
+ return null;
601
+ }
602
+ };
603
+ rawApp.use('*', async (c, next) => {
604
+ const rawHost = c.req.header('host') || '';
605
+ const host = rawHost.split(':')[0].toLowerCase();
606
+ if (!host)
607
+ return next();
608
+ const isPlatformHost = host === __rootDomain || host.endsWith('.' + __rootDomain);
609
+ if (!isPlatformHost)
610
+ return next();
611
+ const sub = host === __rootDomain ? '' : host.slice(0, -(__rootDomain.length + 1));
612
+ const head = sub.split('.').pop() || '';
613
+ if (RESERVED.has(sub) || RESERVED.has(head))
614
+ return next();
615
+ const p = c.req.path;
616
+ if (p.startsWith('/_admin/') || p === '/_admin' || p.startsWith('/.well-known/')) {
617
+ return next();
618
+ }
619
+ // Health and readiness endpoints must always answer 200
620
+ // regardless of whether the requested hostname maps to
621
+ // an env — Cloudflare's container probe (and any
622
+ // upstream load balancer) hits whatever Host header is
623
+ // currently bound to the worker. Returning 404 here on
624
+ // an unmapped hostname would kill the container.
625
+ if (p === '/api/v1/health' || p === '/api/v1/ready' || p === '/health') {
626
+ return next();
627
+ }
628
+ // Resolve env-registry lazily on each request — it may
629
+ // not be registered yet at init() time (registered by
630
+ // ObjectOSProjectPlugin's init which runs in plugin
631
+ // dependency order; we don't want to rely on ordering).
632
+ const registry = getEnvRegistry();
633
+ if (!registry || typeof registry.resolveByHostname !== 'function') {
634
+ return next();
635
+ }
636
+ try {
637
+ const hit = await registry.resolveByHostname(host);
638
+ if (hit)
639
+ return next();
640
+ }
641
+ catch {
642
+ return next();
643
+ }
644
+ // Content negotiation: browsers (Accept: text/html) get
645
+ // a clean 404 page; API clients (curl/fetch with JSON
646
+ // accept) get a structured error body.
647
+ const accept = (c.req.header('accept') || '').toLowerCase();
648
+ const wantsHtml = accept.includes('text/html');
649
+ if (wantsHtml) {
650
+ const safeHost = host.replace(/[<>&"']/g, (ch) => (({
651
+ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;',
652
+ }[ch]) ?? ch));
653
+ const html = `<!doctype html>
654
+ <html lang="en">
655
+ <head>
656
+ <meta charset="utf-8" />
657
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
658
+ <title>404 — Environment not found</title>
659
+ <style>
660
+ :root { color-scheme: light dark; }
661
+ * { box-sizing: border-box; }
662
+ html, body { height: 100%; margin: 0; }
663
+ body {
664
+ font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
665
+ background: #fafafa;
666
+ color: #111;
667
+ display: grid;
668
+ place-items: center;
669
+ padding: 24px;
670
+ }
671
+ @media (prefers-color-scheme: dark) {
672
+ body { background: #0b0b0c; color: #e8e8e8; }
673
+ .card { background: #141417; border-color: #26262b; }
674
+ .host { background: #1c1c20; border-color: #2d2d33; color: #d0d0d0; }
675
+ .muted { color: #8b8b94; }
676
+ a { color: #6ea8fe; }
677
+ }
678
+ .card {
679
+ max-width: 520px;
680
+ width: 100%;
681
+ background: #fff;
682
+ border: 1px solid #e6e6e6;
683
+ border-radius: 12px;
684
+ padding: 32px;
685
+ box-shadow: 0 1px 2px rgba(0,0,0,.04);
686
+ text-align: center;
687
+ }
688
+ .code { font: 600 64px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; margin: 0; letter-spacing: -2px; }
689
+ h1 { font-size: 20px; margin: 16px 0 8px; font-weight: 600; }
690
+ p { margin: 8px 0; }
691
+ .muted { color: #666; font-size: 14px; }
692
+ .host {
693
+ display: inline-block;
694
+ margin-top: 16px;
695
+ padding: 6px 12px;
696
+ background: #f4f4f5;
697
+ border: 1px solid #e4e4e7;
698
+ border-radius: 6px;
699
+ font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
700
+ color: #444;
701
+ word-break: break-all;
702
+ }
703
+ a { color: #2563eb; text-decoration: none; }
704
+ a:hover { text-decoration: underline; }
705
+ </style>
706
+ </head>
707
+ <body>
708
+ <main class="card">
709
+ <p class="code">404</p>
710
+ <h1>Environment not found</h1>
711
+ <p class="muted">No ObjectStack environment is bound to this hostname.</p>
712
+ <div class="host">${safeHost}</div>
713
+ <p class="muted" style="margin-top:24px">
714
+ If you own this domain, bind it to an environment in the
715
+ <a href="https://cloud.objectos.app/">ObjectStack Cloud console</a>.
716
+ </p>
717
+ </main>
718
+ </body>
719
+ </html>`;
720
+ return c.html(html, 404);
721
+ }
722
+ return c.json({
723
+ error: 'environment_not_found',
724
+ message: `No environment is bound to hostname '${host}'.`,
725
+ hostname: host,
726
+ }, 404);
727
+ });
728
+ ctx.logger?.info?.('[unknown-hostname-guard] installed', { rootDomain: __rootDomain });
729
+ }
730
+ catch (err) {
731
+ ctx.logger?.warn?.('[unknown-hostname-guard] install failed', { error: err?.message ?? err });
732
+ }
733
+ },
734
+ };
735
+ try {
736
+ await kernel.use(guardPlugin);
737
+ trackPlugin('UnknownHostnameGuard');
738
+ }
739
+ catch {
740
+ // Best-effort.
741
+ }
742
+ }
450
743
  // 5. Auto-register Studio single-project signal in dev mode.
451
744
  //
452
745
  // `objectstack dev` runs a vanilla user stack (e.g. examples/app-crm)
@@ -498,7 +791,21 @@ export default class Serve extends Command {
498
791
  const secret = process.env.AUTH_SECRET
499
792
  ?? process.env.OS_AUTH_SECRET
500
793
  ?? (isDev ? 'dev-only-insecure-secret-change-me-in-production' : undefined);
501
- if (!secret) {
794
+ // Guard: in cloud-connected runtime mode (e.g. objectos worker)
795
+ // the host kernel is a pure routing shell. Auth is owned by each
796
+ // per-project kernel (`ArtifactKernelFactory` injects an
797
+ // `AuthPlugin` per project against the project's own DB so users
798
+ // persist and stay isolated per subdomain). Injecting a host-level
799
+ // AuthPlugin here would compete with the per-project one — its
800
+ // shared OS_AUTH_SECRET would erroneously validate cookies across
801
+ // unrelated projects. Refuse to inject in runtime mode.
802
+ const cloudUrl = process.env.OS_CLOUD_URL?.trim();
803
+ const isRuntimeMode = !!cloudUrl && cloudUrl.toLowerCase() !== 'local' && cloudUrl.toLowerCase() !== 'off';
804
+ if (isRuntimeMode) {
805
+ console.warn(chalk.yellow(' ⚠ AuthPlugin skipped on host kernel — runtime mode (OS_CLOUD_URL set).\n' +
806
+ ' Auth is owned per-project by ArtifactKernelFactory (see service-cloud).'));
807
+ }
808
+ else if (!secret) {
502
809
  console.warn(chalk.yellow(' ⚠ AuthPlugin skipped — set AUTH_SECRET to enable authentication in production'));
503
810
  }
504
811
  else {
@@ -510,10 +817,127 @@ export default class Serve extends Command {
510
817
  socialProviders.google = { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET };
511
818
  if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET)
512
819
  socialProviders.github = { clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET };
820
+ // Trusted origins (CSRF). better-auth uses a `*` glob that
821
+ // does NOT cross dot-separators, so `http://localhost:*` does
822
+ // not cover `http://<sub>.localhost:*`. Build the allow-list
823
+ // explicitly:
824
+ // - explicit `OS_TRUSTED_ORIGINS` (comma-separated) wins
825
+ // - else dev / preview defaults below
826
+ const trustedOrigins = [];
827
+ const explicitTrusted = process.env.OS_TRUSTED_ORIGINS?.trim();
828
+ if (explicitTrusted) {
829
+ explicitTrusted.split(',').map(s => s.trim()).filter(Boolean).forEach(o => {
830
+ if (!trustedOrigins.includes(o))
831
+ trustedOrigins.push(o);
832
+ });
833
+ }
834
+ // Always add the configured baseUrl so first-party redirects work.
835
+ try {
836
+ const u = new URL(baseUrl);
837
+ const baseOrigin = `${u.protocol}//${u.host}`;
838
+ if (!trustedOrigins.includes(baseOrigin))
839
+ trustedOrigins.push(baseOrigin);
840
+ }
841
+ catch { /* ignore malformed baseUrl */ }
842
+ // Preview-mode subdomain wildcards (`<commit>--<pid>.<base>`).
843
+ // Honour `OS_PREVIEW_BASE_DOMAINS` (used by service-cloud's
844
+ // preview routing) and add `http://*.<base>:*` patterns.
845
+ const previewMode = (process.env.OS_PREVIEW_MODE ?? '').trim().toLowerCase();
846
+ const isPreviewMode = previewMode === '1' || previewMode === 'true' || previewMode === 'yes';
847
+ if (isPreviewMode) {
848
+ const baseDomains = (process.env.OS_PREVIEW_BASE_DOMAINS
849
+ ?? 'preview.objectstack.ai,localhost')
850
+ .split(',').map(s => s.trim()).filter(Boolean);
851
+ for (const dom of baseDomains) {
852
+ const isLoopback = dom === 'localhost' || dom.endsWith('.localhost');
853
+ const scheme = isLoopback ? 'http' : 'https';
854
+ const portSuffix = isLoopback ? ':*' : '';
855
+ const wildcard = `${scheme}://*.${dom}${portSuffix}`;
856
+ if (!trustedOrigins.includes(wildcard))
857
+ trustedOrigins.push(wildcard);
858
+ }
859
+ }
860
+ // Dev convenience: keep `http://localhost:*` so plain
861
+ // `localhost:<port>` still works for non-preview Studio/Console.
862
+ if (isDev && !trustedOrigins.includes('http://localhost:*')) {
863
+ trustedOrigins.push('http://localhost:*');
864
+ }
865
+ // Per-project subdomains: when OS_ROOT_DOMAIN is set (multi-
866
+ // project hosting under `*.<root>`), every project hostname
867
+ // must be trusted by better-auth or sign-up/sign-in is
868
+ // rejected with "Invalid origin". Mirrors the OS_COOKIE_DOMAIN
869
+ // wildcard semantics — they are always set together.
870
+ const rootDomain = (process.env.OS_ROOT_DOMAIN ?? process.env.ROOT_DOMAIN)?.trim();
871
+ if (rootDomain) {
872
+ const wildcard = `https://*.${rootDomain}`;
873
+ if (!trustedOrigins.includes(wildcard))
874
+ trustedOrigins.push(wildcard);
875
+ }
876
+ // Collect application-defined org roles from the stack so
877
+ // Better-Auth's organization plugin accepts invitations to
878
+ // those roles (otherwise it 400s with `ROLE_NOT_FOUND`).
879
+ // Sources:
880
+ // - top-level `roles[]` (role hierarchy entries)
881
+ // - `permissions[]` PermissionSets where `isProfile === true`
882
+ // (these double as role identifiers; e.g. CRM Profiles)
883
+ // Real RBAC enforcement is still owned by SecurityPlugin, which
884
+ // matches these names against `permission` metadata entries.
885
+ const additionalOrgRoles = new Set();
886
+ try {
887
+ const stackAny = config ?? {};
888
+ const collect = (arr) => {
889
+ if (!Array.isArray(arr))
890
+ return;
891
+ for (const r of arr) {
892
+ const n = typeof r === 'string' ? r : (r && typeof r.name === 'string' ? r.name : null);
893
+ if (n && n !== 'owner' && n !== 'admin' && n !== 'member')
894
+ additionalOrgRoles.add(n);
895
+ }
896
+ };
897
+ collect(stackAny.roles);
898
+ if (Array.isArray(stackAny.permissions)) {
899
+ for (const p of stackAny.permissions) {
900
+ if (p && typeof p.name === 'string' && p.isProfile !== false) {
901
+ if (p.name !== 'owner' && p.name !== 'admin' && p.name !== 'member')
902
+ additionalOrgRoles.add(p.name);
903
+ }
904
+ }
905
+ }
906
+ }
907
+ catch {
908
+ // best-effort
909
+ }
513
910
  await kernel.use(new AuthPlugin({
514
911
  secret,
515
912
  baseUrl,
516
913
  socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
914
+ trustedOrigins: trustedOrigins.length ? trustedOrigins : undefined,
915
+ ...(additionalOrgRoles.size > 0 ? { additionalOrgRoles: Array.from(additionalOrgRoles) } : {}),
916
+ // Enable the admin plugin by default so the Setup app's
917
+ // ban/unban/set-password/impersonate/set-role row actions
918
+ // resolve to real endpoints. The plugin self-gates by role
919
+ // (only users whose `role` column is `admin` can hit
920
+ // /admin/* endpoints), so leaving it on for everyone is
921
+ // safe. Opt-out via OS_AUTH_ADMIN=false.
922
+ //
923
+ // Similarly enable twoFactor by default — it powers the
924
+ // Setup app's `sys_two_factor` toolbar actions (Enable 2FA,
925
+ // Disable 2FA). Opt-out via OS_AUTH_TWO_FACTOR=false.
926
+ //
927
+ // (api-key plugin: not yet shipped by better-auth — generic
928
+ // CRUD on `sys_api_key` handles row creation in the meantime.)
929
+ plugins: {
930
+ admin: String(process.env.OS_AUTH_ADMIN ?? 'true').toLowerCase() !== 'false',
931
+ twoFactor: String(process.env.OS_AUTH_TWO_FACTOR ?? 'true').toLowerCase() !== 'false',
932
+ },
933
+ advanced: process.env.OS_COOKIE_DOMAIN
934
+ ? {
935
+ crossSubDomainCookies: {
936
+ enabled: true,
937
+ domain: process.env.OS_COOKIE_DOMAIN,
938
+ },
939
+ }
940
+ : undefined,
517
941
  }));
518
942
  trackPlugin('Auth');
519
943
  // Pair: SecurityPlugin (RBAC) — optional
@@ -594,9 +1018,15 @@ export default class Serve extends Command {
594
1018
  const apiConfig = config.api ?? {};
595
1019
  const enableProjectScoping = apiConfig.enableProjectScoping ?? false;
596
1020
  const projectResolution = apiConfig.projectResolution ?? 'auto';
1021
+ // `requireAuth: true` rejects anonymous requests on `/api/v1/data/*`
1022
+ // with HTTP 401 before they reach ObjectQL. Default-on when the
1023
+ // stack opts in OR when the resolved tier set includes `auth`
1024
+ // (because anonymous data access is almost never desirable when
1025
+ // auth is enabled). Apps can override via stack `api.requireAuth`.
1026
+ const requireAuth = apiConfig.requireAuth ?? (tierEnabled('auth') ? true : false);
597
1027
  try {
598
1028
  const { createRestApiPlugin } = await import('@objectstack/rest');
599
- await kernel.use(createRestApiPlugin({ api: { api: { enableProjectScoping, projectResolution } } }));
1029
+ await kernel.use(createRestApiPlugin({ api: { api: { enableProjectScoping, projectResolution, requireAuth } } }));
600
1030
  trackPlugin('RestAPI');
601
1031
  }
602
1032
  catch (e) {
@@ -702,6 +1132,31 @@ export default class Serve extends Command {
702
1132
  export: 'PackageServicePlugin',
703
1133
  nameMatch: ['service-package', 'PackageServicePlugin'],
704
1134
  },
1135
+ email: {
1136
+ pkg: '@objectstack/plugin-email',
1137
+ export: 'EmailServicePlugin',
1138
+ nameMatch: ['plugin-email', 'EmailServicePlugin'],
1139
+ },
1140
+ sharing: {
1141
+ pkg: '@objectstack/plugin-sharing',
1142
+ export: 'SharingServicePlugin',
1143
+ nameMatch: ['plugin-sharing', 'SharingServicePlugin', 'SharingPlugin'],
1144
+ },
1145
+ reports: {
1146
+ pkg: '@objectstack/plugin-reports',
1147
+ export: 'ReportsServicePlugin',
1148
+ nameMatch: ['plugin-reports', 'ReportsServicePlugin'],
1149
+ },
1150
+ approvals: {
1151
+ pkg: '@objectstack/plugin-approvals',
1152
+ export: 'ApprovalsServicePlugin',
1153
+ nameMatch: ['plugin-approvals', 'ApprovalsServicePlugin'],
1154
+ },
1155
+ settings: {
1156
+ pkg: '@objectstack/service-settings',
1157
+ export: 'SettingsServicePlugin',
1158
+ nameMatch: ['service-settings', 'SettingsServicePlugin'],
1159
+ },
705
1160
  };
706
1161
  const hasPluginMatching = (fragments) => plugins.some((p) => {
707
1162
  const n = String(p?.name ?? '');
@@ -727,6 +1182,74 @@ export default class Serve extends Command {
727
1182
  const cubes = config.analyticsCubes ?? config.cubes ?? [];
728
1183
  arg = { cubes };
729
1184
  }
1185
+ else if (cap === 'email') {
1186
+ // Compose EmailServicePlugin options from config.email + OS_EMAIL_* env.
1187
+ // Env precedence: env beats config so operators can override per-environment.
1188
+ const cfgEmail = config.email ?? {};
1189
+ const envProvider = process.env.OS_EMAIL_PROVIDER;
1190
+ const provider = (envProvider || cfgEmail.provider || 'log').toLowerCase();
1191
+ const apiKey = process.env.OS_EMAIL_API_KEY || cfgEmail.apiKey;
1192
+ const envFrom = process.env.OS_EMAIL_FROM;
1193
+ // OS_EMAIL_FROM supports either "addr@x" or "Name <addr@x>".
1194
+ let defaultFrom = cfgEmail.defaultFrom;
1195
+ if (envFrom) {
1196
+ const m = envFrom.match(/^\s*(?:"?([^"<]*?)"?\s*<\s*([^>]+)\s*>|(\S+))\s*$/);
1197
+ if (m) {
1198
+ const name = (m[1] ?? '').trim();
1199
+ const address = (m[2] ?? m[3] ?? '').trim();
1200
+ if (address)
1201
+ defaultFrom = name ? { name, address } : { address };
1202
+ }
1203
+ }
1204
+ const retries = process.env.OS_EMAIL_RETRIES
1205
+ ? Number(process.env.OS_EMAIL_RETRIES)
1206
+ : cfgEmail.retries;
1207
+ const defaultTemplateContext = {
1208
+ appName: process.env.OS_APP_NAME || cfgEmail.appName || config.appName || 'ObjectStack',
1209
+ ...(cfgEmail.defaultTemplateContext || {}),
1210
+ };
1211
+ // Provide a sensible fallback `from` so templates can render
1212
+ // even before operators configure SMTP/SaaS. The log transport
1213
+ // simply prints to stdout; the address never leaves the box.
1214
+ if (!defaultFrom) {
1215
+ const slug = String(defaultTemplateContext.appName || 'objectstack')
1216
+ .toLowerCase()
1217
+ .replace(/[^a-z0-9]+/g, '-')
1218
+ .replace(/^-+|-+$/g, '') || 'objectstack';
1219
+ defaultFrom = { name: defaultTemplateContext.appName, address: `no-reply@${slug}.local` };
1220
+ }
1221
+ arg = {
1222
+ provider,
1223
+ ...(apiKey ? { apiKey } : {}),
1224
+ defaultFrom,
1225
+ ...(retries != null && !Number.isNaN(retries) ? { retries } : {}),
1226
+ defaultTemplateContext,
1227
+ };
1228
+ if (provider !== 'log' && !apiKey) {
1229
+ console.warn(chalk.yellow(` ⚠ Capability "email": provider='${provider}' but no apiKey found (set OS_EMAIL_API_KEY or config.email.apiKey). Falling back to LogTransport.`));
1230
+ arg.provider = 'log';
1231
+ }
1232
+ }
1233
+ else if (cap === 'storage') {
1234
+ // Storage is now in the default capability slate. If the host
1235
+ // hasn't configured a backend explicitly we fall back to the
1236
+ // local-disk driver under `.objectstack/data/uploads/` so
1237
+ // avatars / attachments / report files work out of the box.
1238
+ // In production mode we emit a single loud warning so the
1239
+ // operator knows to point storage at S3 / GCS / Azure before
1240
+ // shipping (data on a single pod is volatile / non-replicated).
1241
+ const cfgStorage = config.storage;
1242
+ if (cfgStorage && (cfgStorage.driver || cfgStorage.adapter)) {
1243
+ arg = cfgStorage;
1244
+ }
1245
+ else {
1246
+ const root = process.env.OS_STORAGE_ROOT || '.objectstack/data/uploads';
1247
+ arg = { driver: 'local', root };
1248
+ if (!isDev) {
1249
+ console.warn(chalk.yellow(` ⚠ StorageServicePlugin using local driver (${root}) — switch to S3/GCS/Azure for production (set config.storage or OS_STORAGE_*).`));
1250
+ }
1251
+ }
1252
+ }
730
1253
  await kernel.use(arg !== undefined ? new Ctor(arg) : new Ctor());
731
1254
  trackPlugin(spec.export);
732
1255
  if (spec.extras) {
@@ -764,22 +1287,34 @@ export default class Serve extends Command {
764
1287
  if (enableUI) {
765
1288
  // Pre-detect Console availability so we can demote Studio's root
766
1289
  // redirect when the Console is going to claim `/`.
767
- const consolePath = resolveConsolePath();
1290
+ // The `--no-console` flag (or OS_DISABLE_CONSOLE=1 env var) lets a
1291
+ // host (e.g. apps/cloud) opt out of the Console entirely so Studio
1292
+ // owns `/` — useful for control-plane deployments where the
1293
+ // runtime Console is meaningless.
1294
+ const consoleEnabled = flags.console && process.env.OS_DISABLE_CONSOLE !== '1';
1295
+ const consolePath = consoleEnabled ? resolveConsolePath() : null;
768
1296
  const consoleWillMount = !!(consolePath && hasConsoleDist(consolePath));
769
- const studioPath = resolveStudioPath();
770
- if (!studioPath) {
771
- console.warn(chalk.yellow(` ⚠ @objectstack/studio not found skipping UI`));
772
- }
773
- else if (hasStudioDist(studioPath)) {
774
- const distPath = path.join(studioPath, 'dist');
775
- await kernel.use(createStudioStaticPlugin(distPath, {
776
- isDev,
777
- rootRedirect: !consoleWillMount,
778
- }));
779
- trackPlugin('StudioUI');
780
- }
781
- else {
782
- console.warn(chalk.yellow(` ⚠ Studio dist not found — run "pnpm --filter @objectstack/studio build" first`));
1297
+ // The `OS_DISABLE_STUDIO=1` env var lets a host (e.g. apps/cloud,
1298
+ // which is a pure control plane) opt out of the Studio designer
1299
+ // entirely while keeping Account/Console. Studio is meaningless
1300
+ // when there are no per-project kernels in the same process.
1301
+ const studioEnabled = process.env.OS_DISABLE_STUDIO !== '1';
1302
+ if (studioEnabled) {
1303
+ const studioPath = resolveStudioPath();
1304
+ if (!studioPath) {
1305
+ console.warn(chalk.yellow(` ⚠ @objectstack/studio not found — skipping UI`));
1306
+ }
1307
+ else if (hasStudioDist(studioPath)) {
1308
+ const distPath = path.join(studioPath, 'dist');
1309
+ await kernel.use(createStudioStaticPlugin(distPath, {
1310
+ isDev,
1311
+ rootRedirect: !consoleWillMount,
1312
+ }));
1313
+ trackPlugin('StudioUI');
1314
+ }
1315
+ else {
1316
+ console.warn(chalk.yellow(` ⚠ Studio dist not found — run "pnpm --filter @objectstack/studio build" first`));
1317
+ }
783
1318
  }
784
1319
  // ── Account portal ─────────────────────────────────────────
785
1320
  // The account portal sits next to Studio under `/_account/` and
@@ -819,6 +1354,24 @@ export default class Serve extends Command {
819
1354
  // Brief delay to allow logger writes to flush before restoring stdout
820
1355
  await new Promise(r => setTimeout(r, 100));
821
1356
  restoreOutput();
1357
+ // ── Migrate-and-exit short-circuit ─────────────────────────────
1358
+ // Out-of-band migration mode: the caller (e.g.
1359
+ // `apps/cloud/scripts/migrate.ts`) just wants the kernel
1360
+ // bootstrap (ObjectQLPlugin → schema sync → metadata hydration)
1361
+ // to run once against the configured database, then exit. The
1362
+ // HTTP server has already bound `port` at this point but we
1363
+ // never accept a request — shutdown immediately so the deploy
1364
+ // pipeline can move on.
1365
+ if (process.env.OS_MIGRATE_AND_EXIT === '1') {
1366
+ console.log(chalk.green(`✓ Migration complete (${loadedPlugins.length} plugins started against ${redactDbUrl(resolvedDatabaseUrl) || 'configured DB'})`));
1367
+ try {
1368
+ await kernel.shutdown();
1369
+ }
1370
+ catch (err) {
1371
+ console.warn(chalk.yellow(` ⚠ shutdown warning: ${err?.message ?? err}`));
1372
+ }
1373
+ process.exit(0);
1374
+ }
822
1375
  // ── Driver introspection ──────────────────────────────────────
823
1376
  // When the driver was registered by an app preset / per-project
824
1377
  // factory (ProjectKernelFactory) instead of serve.ts's own