-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathruntime-config-plugin.ts
More file actions
203 lines (192 loc) · 10 KB
/
Copy pathruntime-config-plugin.ts
File metadata and controls
203 lines (192 loc) · 10 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
* RuntimeConfigPlugin
*
* Serves `GET /api/v1/runtime/config` (and the legacy alias
* `GET /api/v1/studio/runtime-config`) from a tenant ObjectOS runtime so
* the Console / Studio SPA can learn the upstream cloud URL and capability
* flags **at boot time**, instead of sniffing `window.location.hostname`
* or reading Vite-time env vars.
*
* Response shape (mirrors cloud's `createStudioRuntimeConfigPlugin`):
*
* {
* cloudUrl: string, // base URL of the upstream cloud
* singleEnvironment: false, // multi-tenant runtime
* features: {
* installLocal: boolean, // false here — install-local is owned
* // by CLI `serve` (single-tenant), not
* // by createObjectOSStack
* marketplace: boolean, // true — MarketplaceProxyPlugin mounts
* // /api/v1/marketplace/*
* }
* }
*
* Registers its routes on the Hono raw app, parallel to MarketplaceProxy /
* AuthProxy / MarketplaceInstallLocal plugins.
*/
import type { Plugin, PluginContext } from '@objectstack/core';
import { resolveCloudUrl } from './cloud-url.js';
export interface RuntimeConfigPluginConfig {
/**
* Upstream cloud base URL. Falls back to `resolveCloudUrl()` (reads
* `OS_CLOUD_URL` / built-in default) when omitted. Pass an explicit
* empty string to declare "this runtime IS the cloud" (same-origin
* for marketplace + install).
*/
controlPlaneUrl?: string;
/** Override the `features.installLocal` flag. Default: false. */
installLocal?: boolean;
/**
* Override the `features.aiStudio` flag — whether the SPA should surface
* AI-driven metadata authoring ("online development") affordances. Default:
* true (the actual authoring capability is still gated server-side by the
* presence of the `metadata_assistant` agent / @objectstack/service-ai-studio
* package; set false to force-hide the authoring UI for a tier/deployment).
*/
aiStudio?: boolean;
/**
* Report this runtime as a single-environment deployment (CLI
* `objectstack dev` / `os serve`). Defaults to `false` for
* multi-tenant ObjectOS.
*/
singleEnvironment?: boolean;
/**
* Product name shown in browser title, splash screen, and other
* client chrome. Operators can override per-deployment (white-label,
* regional rebrands). Falls back to `OS_PRODUCT_NAME` env var, then
* to the default `'ObjectOS'`.
*/
productName?: string;
/** Short product name (PWA shortName, compact spots). Defaults to productName. */
productShortName?: string;
}
export class RuntimeConfigPlugin implements Plugin {
readonly name = 'com.objectstack.runtime.runtime-config';
readonly version = '1.0.0';
private readonly cloudUrl: string;
private readonly installLocal: boolean;
private readonly aiStudio: boolean;
private readonly singleEnvironment: boolean;
private readonly productName: string;
private readonly productShortName: string;
constructor(config: RuntimeConfigPluginConfig = {}) {
// An explicit empty string means "stay on this origin" — bypass the
// resolver which would otherwise fall back to the default cloud URL.
this.cloudUrl = config.controlPlaneUrl === ''
? ''
: (resolveCloudUrl(config.controlPlaneUrl) ?? '');
this.installLocal = !!config.installLocal;
this.aiStudio = config.aiStudio !== false; // default true (override-to-hide)
this.singleEnvironment = !!config.singleEnvironment;
const envName = (typeof process !== 'undefined' ? process.env?.OS_PRODUCT_NAME : undefined)?.trim();
const envShort = (typeof process !== 'undefined' ? process.env?.OS_PRODUCT_SHORT_NAME : undefined)?.trim();
this.productName = (config.productName ?? envName ?? 'ObjectOS').trim() || 'ObjectOS';
this.productShortName = (config.productShortName ?? envShort ?? this.productName).trim() || this.productName;
}
init = async (_ctx: PluginContext): Promise<void> => {};
start = async (ctx: PluginContext): Promise<void> => {
ctx.hook('kernel:ready', async () => {
let httpServer: any;
try {
httpServer = ctx.getService('http-server');
} catch {
ctx.logger?.warn?.('[RuntimeConfigPlugin] http-server not available — runtime/config not mounted');
return;
}
if (!httpServer || typeof httpServer.getRawApp !== 'function') {
ctx.logger?.warn?.('[RuntimeConfigPlugin] http-server missing getRawApp() — runtime/config not mounted');
return;
}
const rawApp = httpServer.getRawApp();
// The tenant runtime is multi-tenant: one process serves many
// subdomains, each mapped to one sys_environment row. Telling the
// SPA *which* environment it is attached to (per-request) lets
// the App Marketplace skip the env-picker dialog and install
// directly into "this" env — the operator's domain already
// identifies it.
//
// Hostname → env is resolved by the same registry the per-env
// kernel router uses (env-registry). Falls back to the static
// payload when the host doesn't map to any env (e.g. a
// marketing root, a CLI-served single-env runtime, or
// cloud.objectos.ai which mounts its own static handler).
let envRegistry: any = null;
try { envRegistry = ctx.getService('env-registry'); } catch { /* not mounted (file/CLI mode) */ }
const handler = async (c: any) => {
const rawHost = c.req.header('host') ?? '';
const host = rawHost.split(':')[0].toLowerCase().trim();
let defaultEnvironmentId: string | undefined;
let defaultOrgId: string | undefined;
let resolvedSingleEnv = this.singleEnvironment;
// Capability flag: paid environments unlock the Studio/AI
// online-development surface; free environments hide it. Starts
// from the static default and is overridden per-request once we
// know the resolved environment's billing plan. When the plan
// can't be resolved (file/CLI mode, legacy control plane) we
// keep the static default rather than locking anyone out.
let aiStudio = this.aiStudio;
// EnvironmentDriverRegistry exposes `resolveByHostname()`;
// older code paths used `resolveHostname()` on the client.
// Accept either so production runtimes (which register the
// ArtifactEnvironmentRegistry implementing `resolveByHostname`)
// don't silently no-op and leave the SPA showing the env
// picker on per-subdomain pages.
const resolveFn: ((h: string) => Promise<any>) | null =
typeof envRegistry?.resolveByHostname === 'function'
? envRegistry.resolveByHostname.bind(envRegistry)
: typeof envRegistry?.resolveHostname === 'function'
? envRegistry.resolveHostname.bind(envRegistry)
: null;
if (resolveFn && host) {
try {
const resolved = await resolveFn(host);
if (resolved?.environmentId) {
defaultEnvironmentId = String(resolved.environmentId);
const orgId = resolved.organizationId ?? resolved.organization_id;
if (orgId) defaultOrgId = String(orgId);
// Each subdomain is one environment from the
// operator's POV: surface as single-environment
// so the SPA hides multi-env affordances.
resolvedSingleEnv = true;
// Gate the Studio/AI surface on the environment's
// plan: free → off, any paid tier → on. Only an
// explicit non-empty plan overrides the default, so
// an absent/blank value leaves the static flag intact.
const plan = typeof resolved.plan === 'string' ? resolved.plan.trim().toLowerCase() : '';
if (plan) aiStudio = plan !== 'free';
}
} catch {
// Resolver failures are non-fatal — fall through
// to the static payload so /runtime/config never
// 500s. Worst case the SPA shows its env picker.
}
}
return c.json({
cloudUrl: this.cloudUrl,
singleEnvironment: resolvedSingleEnv,
defaultOrgId,
defaultEnvironmentId,
features: {
installLocal: this.installLocal,
marketplace: true,
aiStudio,
},
branding: {
productName: this.productName,
productShortName: this.productShortName,
},
});
};
rawApp.get('/api/v1/runtime/config', handler);
// Legacy alias for older Studio bundles.
rawApp.get('/api/v1/studio/runtime-config', handler);
ctx.logger?.info?.('[RuntimeConfigPlugin] mounted /api/v1/runtime/config', {
cloudUrl: this.cloudUrl || '(empty)',
installLocal: this.installLocal,
perHostEnvResolution: !!envRegistry,
});
});
};
destroy = async (): Promise<void> => {};
}