@horizonmedia/blu-sdk 0.0.0-stage → 1.8.4

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.
package/bin/blu.js ADDED
@@ -0,0 +1,343 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ REGISTRY_URL,
4
+ validateModuleManifest
5
+ } from "../chunks/chunk-CSQHQLB7.js";
6
+
7
+ // src/cli/blu.js
8
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".next", "out", "coverage", ".git", ".turbo", "public", "__pycache__", "venv", "site-packages"]);
12
+ var CODE = /\.(m?[jt]sx?|cjs|py)$/;
13
+ var isPy = (f) => f.endsWith(".py");
14
+ var usesPySdk = (src) => /^\s*(?:from\s+blu_sdk(?:\.\w+)*\s+import|import\s+blu_sdk)\b/m.test(src);
15
+ var FORBIDDEN_IMPORTS = [
16
+ { re: /^@blu\/sdk\/shell$/, fail: true, why: "is internal to the Blu shell. Read the context with the hooks in @blu/sdk." },
17
+ { re: /^@blu\/contracts$/, fail: true, why: "is internal to Blu. Validate your manifest with `blu check`." },
18
+ { re: /^@blu\/host-sdk\/frame$/, fail: true, why: "was renamed: import from @blu/sdk/frame." },
19
+ { re: /^@blu\/host-sdk$/, fail: true, why: "was renamed: import from @blu/sdk." },
20
+ { re: /^@blu\/service-sdk$/, fail: true, why: "was renamed: import from @blu/sdk/server." },
21
+ { re: /^@blu\/module-dev-harness$/, fail: true, why: "was renamed: import from @blu/sdk/dev." },
22
+ { re: /^@blu\/(sdk|next-adapter)\/(src|dist|types|chunks)\b/, fail: true, why: "reaches inside the package. Import only its documented entry points." },
23
+ { re: /^@horizonmedia\/blu-(sdk|next-adapter)(\/|$)/, fail: false, why: "uses the published name. Import it as @blu/sdk (or @blu/next-adapter) and alias it in package.json." }
24
+ ];
25
+ function sdkVersion() {
26
+ const here = path.dirname(fileURLToPath(import.meta.url));
27
+ for (const p of [path.join(here, "package.json"), path.join(here, "../../package.json")]) {
28
+ try {
29
+ const pkg = JSON.parse(readFileSync(p, "utf8"));
30
+ if (pkg.name === "@horizonmedia/blu-sdk" || pkg.name === "@blu/sdk") return pkg.version;
31
+ } catch {
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+ function walk(dir, out = []) {
37
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
38
+ if (e.name.startsWith(".") && e.name !== ".npmrc") continue;
39
+ const p = path.join(dir, e.name);
40
+ if (e.isDirectory()) {
41
+ if (!SKIP_DIRS.has(e.name)) walk(p, out);
42
+ } else out.push(p);
43
+ }
44
+ return out;
45
+ }
46
+ function findModuleRoot(dir) {
47
+ if (existsSync(path.join(dir, "blu.module.json"))) return dir;
48
+ const found = walk(dir).filter((f) => path.basename(f) === "blu.module.json");
49
+ if (found.length === 1) return path.dirname(found[0]);
50
+ if (found.length > 1) throw new Error(`Several blu.module.json files under ${dir}; run blu check in one module's folder:
51
+ ${found.join("\n ")}`);
52
+ throw new Error(`No blu.module.json in ${dir} or below. Run blu check in your module's folder.`);
53
+ }
54
+ var pyImportsOf = (src) => [...src.matchAll(/^\s*(?:from\s+([\w.]+)\s+import|import\s+([\w.]+))/gm)].map((m) => m[1] ?? m[2]);
55
+ function nearestPythonManifest(file, root) {
56
+ let d = path.dirname(file);
57
+ const stop = path.dirname(root);
58
+ while (d.startsWith(stop)) {
59
+ const found = ["pyproject.toml", "requirements.txt", "requirements.in"].map((n) => path.join(d, n)).find((p) => existsSync(p));
60
+ if (found) return found;
61
+ if (d === stop) break;
62
+ d = path.dirname(d);
63
+ }
64
+ return null;
65
+ }
66
+ var importsOf = (src) => [...src.matchAll(/(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)["']([^"']+)["']/g)].map((m) => m[1]);
67
+ function nearestPackageJson(file, root) {
68
+ let d = path.dirname(file);
69
+ const stop = path.dirname(root);
70
+ while (d.startsWith(stop)) {
71
+ const p = path.join(d, "package.json");
72
+ if (existsSync(p)) return p;
73
+ if (d === stop) break;
74
+ d = path.dirname(d);
75
+ }
76
+ return null;
77
+ }
78
+ function githubPackagesNpmrc(dir) {
79
+ let d = dir;
80
+ for (; ; ) {
81
+ const p = path.join(d, ".npmrc");
82
+ if (existsSync(p) && /^\s*@horizonmedia:registry\s*=\s*https:\/\/npm\.pkg\.github\.com/m.test(readFileSync(p, "utf8"))) return p;
83
+ const up = path.dirname(d);
84
+ if (up === d) return null;
85
+ d = up;
86
+ }
87
+ }
88
+ var below = (a, b) => {
89
+ const pa = a.split(".").map(Number);
90
+ const pb = b.split(".").map(Number);
91
+ for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) < (pb[i] ?? 0);
92
+ return false;
93
+ };
94
+ function check(dir, { simulatedDns = false } = {}) {
95
+ const root = findModuleRoot(path.resolve(dir));
96
+ const results = [];
97
+ const add = (status, area, message, file) => results.push({ status, area, message, ...file ? { file: path.relative(root, file) } : {} });
98
+ let manifest;
99
+ try {
100
+ manifest = JSON.parse(readFileSync(path.join(root, "blu.module.json"), "utf8"));
101
+ } catch (e) {
102
+ add("fail", "manifest", `blu.module.json isn't valid JSON: ${e.message}`);
103
+ return { root, manifest: null, results };
104
+ }
105
+ const problems = validateModuleManifest(manifest, { simulatedDns });
106
+ for (const p of problems) add("fail", "manifest", p);
107
+ const routes = new Set((manifest.routes ?? []).map((r) => r.name));
108
+ const tools = (manifest.mcp?.tools ?? []).map((t) => t.name);
109
+ if (!problems.length) {
110
+ const mounts = [manifest.remote && "inline", manifest.frame && "isolated"].filter(Boolean).join(" + ");
111
+ add("pass", "manifest", `valid: module "${manifest.id}", ${mounts}, ${routes.size} route(s), ${tools.length} Concierge tool(s)`);
112
+ }
113
+ const unversioned = Object.entries(manifest.environments ?? {}).flatMap(
114
+ ([stage, e]) => ["remoteEntry", "frameEntry"].filter((k) => typeof e?.[k] === "string" && !e[k].includes("{version}") && !(manifest.version && e[k].includes(`/${manifest.version}/`))).map((k) => `${stage}.${k}`)
115
+ );
116
+ if (unversioned.length) {
117
+ add("warn", "releases", `${unversioned.join(", ")} don't name the release's folder. Use {version} in the address (\u2026/{version}/remoteEntry.js), so each release keeps its own files and rollback works.`);
118
+ } else if (Object.keys(manifest.environments ?? {}).length) add("pass", "releases", "bundle addresses are per release ({version})");
119
+ const files = walk(root).filter((f) => CODE.test(f));
120
+ const sources = new Map(files.map((f) => [f, readFileSync(f, "utf8")]));
121
+ const usingSdk = /* @__PURE__ */ new Map();
122
+ let badImports = 0;
123
+ const usingPySdk = /* @__PURE__ */ new Set();
124
+ for (const [f, src] of sources) {
125
+ if (isPy(f)) {
126
+ for (const mod of pyImportsOf(src)) {
127
+ if (/^blu_sdk\._/.test(mod)) {
128
+ add("fail", "imports", `"${mod}" is internal to blu-sdk. Import from blu_sdk or its documented modules (blu_sdk.fastapi, .flask, .django, .asgi, .mcp, .contracts).`, f);
129
+ badImports++;
130
+ } else if (/^blu_platform(\.|$)/.test(mod)) {
131
+ add("fail", "imports", `"${mod}" is internal to Blu. Use blu-sdk.`, f);
132
+ badImports++;
133
+ }
134
+ }
135
+ if (usesPySdk(src)) {
136
+ const manifestFile = nearestPythonManifest(f, root);
137
+ usingPySdk.add(manifestFile ?? f);
138
+ }
139
+ continue;
140
+ }
141
+ for (const spec of importsOf(src)) {
142
+ const rule = FORBIDDEN_IMPORTS.find((r) => r.re.test(spec));
143
+ if (rule) {
144
+ add(rule.fail ? "fail" : "warn", "imports", `"${spec}" ${rule.why}`, f);
145
+ badImports++;
146
+ }
147
+ if (/^@blu\/(sdk|next-adapter)(\/|$)/.test(spec)) {
148
+ const pj = nearestPackageJson(f, root);
149
+ if (pj) usingSdk.set(pj, /* @__PURE__ */ new Set([...usingSdk.get(pj) ?? [], spec.split("/").slice(0, 2).join("/")]));
150
+ }
151
+ }
152
+ }
153
+ if (!badImports) add("pass", "imports", `only public ${usingPySdk.size ? "blu-sdk, " : ""}@blu/sdk and @blu/next-adapter entry points`);
154
+ const current = sdkVersion();
155
+ const oldNpmrcs = /* @__PURE__ */ new Set();
156
+ for (const [pj, used] of usingSdk) {
157
+ const pkg = JSON.parse(readFileSync(pj, "utf8"));
158
+ const deps = { ...pkg.devDependencies, ...pkg.peerDependencies, ...pkg.dependencies };
159
+ for (const name of used) {
160
+ const spec = deps[name];
161
+ const published = name === "@blu/sdk" ? "@horizonmedia/blu-sdk" : "@horizonmedia/blu-next-adapter";
162
+ if (!spec) {
163
+ add("fail", "dependencies", `${name} is imported but not in package.json. Add "${name}": "npm:${published}@^${name === "@blu/sdk" && current ? current : "1.0.0"}".`, pj);
164
+ } else if (spec.startsWith("workspace:")) {
165
+ add("pass", "dependencies", `${name}: workspace package (the Blu repo itself)`, pj);
166
+ } else if (!spec.startsWith(`npm:${published}@`)) {
167
+ add("fail", "dependencies", `${name} is "${spec}". Use the published package under that name: "npm:${published}@^\u2026".`, pj);
168
+ } else {
169
+ const min = /@[~^]?(\d+\.\d+\.\d+)/.exec(spec.slice(4 + published.length))?.[1];
170
+ if (name === "@blu/sdk" && current && min && below(min, current)) {
171
+ add("warn", "dependencies", `${name} allows ${min}; this check came with ${current}. Raise the range to ^${current} to build against what the Blu shell provides.`, pj);
172
+ } else add("pass", "dependencies", `${name}: ${spec}`, pj);
173
+ const npmrc = githubPackagesNpmrc(path.dirname(pj));
174
+ if (npmrc && !oldNpmrcs.has(npmrc)) {
175
+ oldNpmrcs.add(npmrc);
176
+ add("warn", "dependencies", `${path.relative(process.cwd(), npmrc) || ".npmrc"} points @horizonmedia at GitHub Packages. The Blu SDKs are public on npm now: remove that line (and any token for it), so new versions install from registry.npmjs.org.`, pj);
177
+ }
178
+ }
179
+ }
180
+ const zodMin = /(\d+\.\d+\.\d+)/.exec(deps.zod ?? "")?.[1];
181
+ if (zodMin && below(zodMin, "4.6.6")) {
182
+ add("warn", "dependencies", `zod allows ${zodMin}: versions before 4.6.6 can exhaust memory validating oversized input (GHSA-mrhx-c7xc-m3w5). Use "zod": "^4.6.7".`, pj);
183
+ }
184
+ const reactSpec = deps.react;
185
+ if (manifest.remote && reactSpec && !/(^|[^\d.])19(\.|$|\b)|\*|latest|>=\s*1[89]/.test(reactSpec)) {
186
+ add(
187
+ "warn",
188
+ "react",
189
+ `react is "${reactSpec}": inside Blu this module runs on the shell's React 19 (one shared copy). React 18 code generally works there; avoid what 19 removed: ReactDOM.render/hydrate, string refs, legacy context (contextTypes), defaultProps on function components, findDOMNode. Test it in the shell before releasing.`,
190
+ pj
191
+ );
192
+ }
193
+ }
194
+ for (const file of usingPySdk) {
195
+ if (!/\.(toml|txt|in)$/.test(file)) {
196
+ add("fail", "dependencies", "Python code imports blu_sdk but there's no pyproject.toml or requirements.txt declaring blu-sdk.", file);
197
+ continue;
198
+ }
199
+ const line = readFileSync(file, "utf8").split("\n").find((l) => /^\s*["']?blu-sdk\b/.test(l));
200
+ const spec = line?.trim().replace(/^["']|["'],?$/g, "");
201
+ if (!spec) add("fail", "dependencies", `blu_sdk is imported but blu-sdk isn't declared. Add "blu-sdk @ git+https://github.com/HorizonMedia/blu-composable-experience@sdk-py-v1.2.0#subdirectory=packages/sdk-python".`, file);
202
+ else if (/git\+/.test(spec) && !/@sdk-py-v\d+\.\d+\.\d+/.test(spec)) add("warn", "dependencies", `blu-sdk is installed from Git without a release tag ("${spec}"). Pin @sdk-py-vX.Y.Z so builds are repeatable.`, file);
203
+ else add("pass", "dependencies", `blu-sdk: ${spec}`, file);
204
+ }
205
+ if (!usingSdk.size && !usingPySdk.size) add("warn", "dependencies", "no code here imports @blu/sdk or blu_sdk yet");
206
+ const grep = (re) => [...sources].filter(([, s]) => re.test(s)).map(([f]) => f);
207
+ const usesNextAdapter = [...usingSdk.values()].some((u) => u.has("@blu/next-adapter"));
208
+ if (manifest.remote) {
209
+ const name = manifest.remote.name;
210
+ if (usesNextAdapter) {
211
+ const configs = grep(/\bbluModule\s*\(/);
212
+ const named = configs.filter((f) => new RegExp(`\\bname\\s*:\\s*["']${name}["']`).test(sources.get(f)));
213
+ if (!configs.length) add("fail", "inline", "uses @blu/next-adapter but no Vite config calls bluModule(). Add vite.blu.config.ts.");
214
+ else if (!named.length) add("fail", "inline", `bluModule() isn't given name "${name}" (manifest remote.name).`, configs[0]);
215
+ else add("pass", "inline", `bluModule({ name: "${name}" }) matches the manifest`, named[0]);
216
+ } else {
217
+ const configs = grep(/\bfederation\s*\(/);
218
+ if (!configs.length) {
219
+ add("fail", "inline", "the manifest declares `remote` (inline) but no Vite config calls federation(). Add @module-federation/vite.");
220
+ } else {
221
+ for (const f of configs) {
222
+ const src = sources.get(f);
223
+ if (!new RegExp(`\\bname\\s*:\\s*["']${name}["']`).test(src)) add("fail", "inline", `federation() name isn't "${name}" (manifest remote.name).`, f);
224
+ if (!src.includes(`"${manifest.remote.exposed}"`) && !src.includes(`'${manifest.remote.exposed}'`)) {
225
+ add("fail", "inline", `federation() doesn't expose "${manifest.remote.exposed}" (manifest remote.exposed).`, f);
226
+ }
227
+ if (!/["']@blu\/sdk["']\s*:\s*\{[^}]*singleton\s*:\s*true/.test(src)) {
228
+ add("fail", "inline", 'federation() must share "@blu/sdk": { singleton: true }. Two copies would mean two contexts, and the hooks would find no Blu.', f);
229
+ } else add("pass", "inline", `federation() "${name}" shares @blu/sdk as a singleton`, f);
230
+ }
231
+ }
232
+ }
233
+ }
234
+ if (manifest.frame) {
235
+ const frames = grep(/\b(mountIsolated|connectToBluShell)\s*\(/);
236
+ if (frames.length) add("pass", "isolated", "the frame page connects with mountIsolated / connectToBluShell", frames[0]);
237
+ else add("fail", "isolated", "the manifest declares `frame` (isolated) but nothing calls mountIsolated() or connectToBluShell() from @blu/sdk/frame.");
238
+ }
239
+ const servers = [...grep(/["']@blu\/(sdk\/server|next-adapter\/server)["']/), ...[...sources].filter(([f, s]) => isPy(f) && usesPySdk(s)).map(([f]) => f)];
240
+ if (!servers.length) {
241
+ add("warn", "server", "no code here uses @blu/sdk/server, @blu/next-adapter/server or blu_sdk (Python). If your API lives in another repo, run blu check there too.");
242
+ } else {
243
+ let mismatched = false;
244
+ for (const f of servers) {
245
+ for (const m of sources.get(f).matchAll(/\b(?:moduleId\s*:|module_id\s*=)\s*["']([^"']+)["']/g)) {
246
+ if (m[1] !== manifest.id) {
247
+ add("fail", "server", `moduleId "${m[1]}" isn't the manifest's id "${manifest.id}". Blu issues your tokens for blu-module:${manifest.id}.`, f);
248
+ mismatched = true;
249
+ }
250
+ }
251
+ }
252
+ if (!mismatched) add("pass", "server", `${servers.length} file(s) use the server SDK; module ids match the manifest`);
253
+ }
254
+ if (tools.length) {
255
+ const endpoints = [...sources].filter(([f, s]) => isPy(f) ? usesPySdk(s) && /(?:\.(?:mount_)?mcp|\bMcpEndpoint)\s*\(/.test(s) : /\bcreateMcp(Endpoint|WebHandler)\s*\(/.test(s)).map(([f]) => f);
256
+ if (!endpoints.length) {
257
+ add("fail", "mcp", `the manifest declares ${tools.length} tool(s) but no code serves them: createMcpEndpoint() / createMcpWebHandler() (JS), or blu.mount_mcp(...) / McpEndpoint(...) (Python).`);
258
+ } else {
259
+ const withManifest = endpoints.filter((f) => /(?:\bcreateMcp(?:Endpoint|WebHandler)|\.(?:mount_)?mcp|\bMcpEndpoint)\s*\([\s\S]{0,400}?\bmanifest\b/.test(sources.get(f)));
260
+ if (!withManifest.length) add("fail", "mcp", "pass your blu.module.json as `manifest` to the MCP endpoint, so tools that don't match it stop the API from starting.", endpoints[0]);
261
+ const missing = tools.filter((t) => !grep(new RegExp(`\\bname\\s*[:=]\\s*["']${t}["']`)).length);
262
+ for (const t of missing) add("fail", "mcp", `tool "${t}" is declared in the manifest but no code defines it (name: "${t}").`);
263
+ if (withManifest.length && !missing.length) add("pass", "mcp", `${tools.length} tool(s) defined; the endpoint is checked against the manifest at startup`, withManifest[0]);
264
+ }
265
+ }
266
+ let links = 0;
267
+ for (const [f, src] of sources) {
268
+ if (!/["']@blu\/sdk\/server["']/.test(src) && !(isPy(f) && usesPySdk(src))) continue;
269
+ for (const m of src.matchAll(/\blink\s*\(\s*["']([^"']+)["']/g)) {
270
+ links++;
271
+ if (!routes.has(m[1])) add("fail", "links", `link("${m[1]}", \u2026) names a route the manifest doesn't declare. Add it to routes, or link to one that exists.`, f);
272
+ }
273
+ }
274
+ if (links && !results.some((r) => r.area === "links")) add("pass", "links", `${links} link(s), all to declared routes`);
275
+ return { root, manifest, results };
276
+ }
277
+ async function register(rest) {
278
+ const flag = rest.indexOf("--registry");
279
+ const registry = (flag >= 0 ? rest[flag + 1] : process.env.BLU_REGISTRY_URL) || REGISTRY_URL;
280
+ const dir = rest.find((a, i) => !a.startsWith("--") && rest[i - 1] !== "--registry") ?? process.cwd();
281
+ const token = process.env.BLU_CI_TOKEN;
282
+ if (!token) {
283
+ console.error("blu register: set BLU_CI_TOKEN to your module's CI token (ask the Blu team for it).");
284
+ return 2;
285
+ }
286
+ let manifest;
287
+ try {
288
+ manifest = JSON.parse(readFileSync(path.join(findModuleRoot(path.resolve(dir)), "blu.module.json"), "utf8"));
289
+ } catch (e) {
290
+ console.error(`blu register: ${e.message}`);
291
+ return 2;
292
+ }
293
+ const url = `${registry.replace(/\/$/, "")}/v1/modules/${encodeURIComponent(manifest.id)}/versions`;
294
+ let res;
295
+ try {
296
+ res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify(manifest) });
297
+ } catch (e) {
298
+ console.error(`blu register: couldn't reach ${registry}: ${e.cause?.message ?? e.message}`);
299
+ return 1;
300
+ }
301
+ const body = await res.json().catch(() => ({}));
302
+ if (!res.ok) {
303
+ console.error(`\u2718 ${manifest.id}@${manifest.version}: ${body.detail ?? body.error ?? `HTTP ${res.status}`}`);
304
+ for (const p of body.problems ?? []) console.error(` - ${p}`);
305
+ return 1;
306
+ }
307
+ console.log(`\u2714 ${manifest.id}@${body.version} ${body.created ? "registered" : "was already registered (unchanged)"}`);
308
+ for (const [env, status] of Object.entries(body.environments ?? {})) console.log(` ${env.padEnd(8)} ${status}`);
309
+ if (body.review) console.log(` Blu admins review it at ${body.review}`);
310
+ return 0;
311
+ }
312
+ async function main(argv) {
313
+ const [cmd, ...rest] = argv;
314
+ if (cmd === "register") return register(rest);
315
+ if (cmd !== "check") {
316
+ console.log("Usage: blu check [dir] [--json] [--simulated-dns]\n blu register [dir] [--registry <url>] (BLU_CI_TOKEN = the module's CI token)");
317
+ return cmd === void 0 || cmd === "--help" || cmd === "-h" ? 0 : 2;
318
+ }
319
+ const json = rest.includes("--json");
320
+ const simulatedDns = rest.includes("--simulated-dns");
321
+ const dir = rest.find((a) => !a.startsWith("--")) ?? process.cwd();
322
+ let report;
323
+ try {
324
+ report = check(dir, { simulatedDns });
325
+ } catch (e) {
326
+ console.error(`blu check: ${e.message}`);
327
+ return 2;
328
+ }
329
+ const fails = report.results.filter((r) => r.status === "fail").length;
330
+ const warns = report.results.filter((r) => r.status === "warn").length;
331
+ if (json) {
332
+ console.log(JSON.stringify({ root: report.root, module: report.manifest?.id ?? null, ok: fails === 0, ...report }, null, 2));
333
+ } else {
334
+ console.log(`blu check: ${report.root}${report.manifest?.id ? ` (module "${report.manifest.id}")` : ""}`);
335
+ const icon = { pass: "\u2714", warn: "\u26A0", fail: "\u2718" };
336
+ for (const r of report.results) console.log(`${icon[r.status]} ${r.area}: ${r.message}${r.file ? ` [${r.file}]` : ""}`);
337
+ console.log(fails ? `
338
+ ${fails} problem(s)${warns ? `, ${warns} warning(s)` : ""}` : `
339
+ All checks passed${warns ? ` (${warns} warning(s))` : ""}.`);
340
+ }
341
+ return fails ? 1 : 0;
342
+ }
343
+ process.exitCode = await main(process.argv.slice(2));
@@ -0,0 +1,163 @@
1
+ // ../contracts/src/index.js
2
+ var MANIFEST_VERSION = 1;
3
+ var SUPPORTED_CONTRACT_MAJORS = [1];
4
+ var STAGES = ["local", "dev", "staging", "prod"];
5
+ function isLocalHost(hostname) {
6
+ const h = String(hostname).replace(/\.$/, "").toLowerCase();
7
+ return h === "localhost" || h.endsWith(".localhost") || /^127(\.\d{1,3}){3}$/.test(h) || h === "[::1]";
8
+ }
9
+ var isLocalUrl = (url) => {
10
+ try {
11
+ return isLocalHost(new URL(url).hostname);
12
+ } catch {
13
+ return false;
14
+ }
15
+ };
16
+ var ENTRY_POINTS = ["blu", "service", "team", "api-key"];
17
+ var ID = /^[a-z][a-z0-9-]{1,40}$/;
18
+ var TOOL = /^[a-z][a-z0-9_]{1,50}$/;
19
+ var ROUTE_NAME = /^[a-z][a-z0-9-]{0,40}$/;
20
+ var ROUTE_SEGMENT = /^(:[a-zA-Z][a-zA-Z0-9]{0,30}|[a-z0-9][a-z0-9._-]*)$/;
21
+ function isLoopbackOrIp(hostname) {
22
+ const h = hostname.replace(/\.$/, "").toLowerCase();
23
+ return h === "localhost" || h.endsWith(".localhost") || /^\d{1,3}(\.\d{1,3}){3}$/.test(h) || h.startsWith("[");
24
+ }
25
+ function redirectUriProblems(uri, stage, { simulatedDns = false } = {}) {
26
+ let url;
27
+ try {
28
+ url = new URL(uri);
29
+ } catch {
30
+ return ["must be an absolute URL"];
31
+ }
32
+ const problems = [];
33
+ if (url.protocol !== "https:" && url.protocol !== "http:") problems.push("must be http(s)");
34
+ if (/[*\s#]/.test(uri)) problems.push("no wildcards, spaces or fragments");
35
+ if (url.username || url.password) problems.push("no user:password@");
36
+ if (stage !== "local") {
37
+ const simulated = simulatedDns && url.hostname.endsWith(".localhost");
38
+ if (isLoopbackOrIp(url.hostname) && !simulated) problems.push(`localhost and IP addresses are only allowed in local, not ${stage}`);
39
+ if (url.protocol !== "https:" && !simulated) problems.push(`must be https in ${stage}`);
40
+ }
41
+ return problems;
42
+ }
43
+ function validateModuleManifest(m, opts = {}) {
44
+ const problems = [];
45
+ const req = (cond, msg) => cond || problems.push(msg);
46
+ if (!m || typeof m !== "object") return ["manifest must be a JSON object"];
47
+ req(m.manifestVersion === MANIFEST_VERSION, `manifestVersion must be ${MANIFEST_VERSION}`);
48
+ req(typeof m.id === "string" && ID.test(m.id), "id: lowercase letters, digits, dashes");
49
+ req(typeof m.displayName === "string" && m.displayName.length > 0, "displayName required");
50
+ req(typeof m.owner === "string" && m.owner.length > 0, "owner required");
51
+ req(typeof m.version === "string" && /^\d+\.\d+\.\d+/.test(m.version), "version must be semver");
52
+ const major = Number(String(m.contractVersion ?? "").split(".")[0]);
53
+ req(SUPPORTED_CONTRACT_MAJORS.includes(major), `contractVersion major must be one of ${SUPPORTED_CONTRACT_MAJORS.join(", ")} (admission floor)`);
54
+ const inline = m.remote !== void 0;
55
+ const isolated = m.frame !== void 0;
56
+ req(inline || isolated, "declare `remote` (inline, Module Federation) and/or `frame` (isolated iframe)");
57
+ if (inline) req(typeof m.remote?.name === "string" && typeof m.remote?.exposed === "string", "remote.name and remote.exposed required");
58
+ if (isolated) {
59
+ req(m.frame && typeof m.frame === "object", "frame must be an object");
60
+ req(m.frame?.routing === void 0 || ["bridge", "url"].includes(m.frame.routing), 'frame.routing must be "bridge" or "url"');
61
+ req(m.frame?.inlineScripts === void 0 || typeof m.frame.inlineScripts === "boolean", "frame.inlineScripts must be true/false");
62
+ }
63
+ req(m.navDefaults && typeof m.navDefaults.path === "string" && m.navDefaults.path.startsWith("/"), "navDefaults.path required");
64
+ req(m.navDefaults?.moduleInfo === void 0 || typeof m.navDefaults.moduleInfo === "boolean", "navDefaults.moduleInfo must be true/false");
65
+ const envs = Object.keys(m.environments ?? {});
66
+ req(envs.length > 0, "environments: at least one stage");
67
+ for (const stage of envs) {
68
+ req(STAGES.includes(stage), `environments.${stage}: unknown stage`);
69
+ for (const k of ["apiBase", "mcpUrl", ...inline ? ["remoteEntry"] : [], ...isolated ? ["frameEntry"] : []]) {
70
+ const v = m.environments[stage]?.[k];
71
+ req(typeof v === "string" && /^https?:\/\//.test(v), `environments.${stage}.${k} must be an absolute URL`);
72
+ }
73
+ const redirects = m.environments[stage]?.standaloneRedirectUris;
74
+ if (redirects !== void 0) {
75
+ req(Array.isArray(redirects), `environments.${stage}.standaloneRedirectUris must be an array`);
76
+ for (const u of Array.isArray(redirects) ? redirects : []) {
77
+ const issues = typeof u === "string" ? redirectUriProblems(u, stage, opts) : ["must be a string"];
78
+ req(issues.length === 0, `environments.${stage}.standaloneRedirectUris: "${u}" ${issues.join("; ")}`);
79
+ }
80
+ }
81
+ }
82
+ if (m.routes !== void 0) {
83
+ req(Array.isArray(m.routes), "routes must be an array");
84
+ const names = /* @__PURE__ */ new Set();
85
+ for (const r of Array.isArray(m.routes) ? m.routes : []) {
86
+ req(typeof r?.name === "string" && ROUTE_NAME.test(r.name), `routes: bad name "${r?.name}"`);
87
+ req(!names.has(r?.name), `routes: duplicate "${r?.name}"`);
88
+ names.add(r?.name);
89
+ const segs = typeof r?.path === "string" && r.path.startsWith("/") ? r.path.slice(1).split("/") : null;
90
+ req(segs !== null && segs.every((x) => ROUTE_SEGMENT.test(x)), `routes.${r?.name}: path must be /literal/:param/\u2026 (got "${r?.path}")`);
91
+ req(typeof r?.title === "string" && r.title.length > 0, `routes.${r?.name}: title required`);
92
+ }
93
+ }
94
+ req(Array.isArray(m.serviceScopes), "serviceScopes must be an array");
95
+ const tools = m.mcp?.tools;
96
+ req(Array.isArray(tools), "mcp.tools must be an array");
97
+ const seen = /* @__PURE__ */ new Set();
98
+ for (const t of tools ?? []) {
99
+ req(typeof t.name === "string" && TOOL.test(t.name), `mcp.tools: bad name "${t.name}"`);
100
+ req(!seen.has(t.name), `mcp.tools: duplicate "${t.name}"`);
101
+ seen.add(t.name);
102
+ req(typeof t.readOnly === "boolean", `mcp.tools.${t.name}: readOnly must be true/false`);
103
+ const eps = Object.keys(t.requires ?? {});
104
+ req(eps.length > 0, `mcp.tools.${t.name}: requires must name at least one entry point`);
105
+ for (const ep of eps) {
106
+ req(ENTRY_POINTS.includes(ep), `mcp.tools.${t.name}.requires: unknown entry point "${ep}"`);
107
+ req(Array.isArray(t.requires[ep]), `mcp.tools.${t.name}.requires.${ep} must be an array`);
108
+ }
109
+ }
110
+ return problems;
111
+ }
112
+ var same = (a, b) => JSON.stringify([...a ?? []].sort()) === JSON.stringify([...b ?? []].sort());
113
+ function diffToolsAgainstManifest(codeTools, manifest) {
114
+ const problems = [];
115
+ const declared = new Map((manifest.mcp?.tools ?? []).map((t) => [t.name, t]));
116
+ for (const t of codeTools) {
117
+ const d = declared.get(t.name);
118
+ if (!d) {
119
+ problems.push(`tool "${t.name}" is in code but not declared in blu.module.json`);
120
+ continue;
121
+ }
122
+ declared.delete(t.name);
123
+ const readOnly = t.annotations?.readOnlyHint === true;
124
+ if (readOnly !== d.readOnly) problems.push(`tool "${t.name}": readOnly is ${readOnly} in code, ${d.readOnly} in manifest`);
125
+ const eps = /* @__PURE__ */ new Set([...Object.keys(t.policy ?? {}), ...Object.keys(d.requires ?? {})]);
126
+ for (const ep of eps) {
127
+ if (!same(t.policy?.[ep], d.requires?.[ep])) {
128
+ problems.push(`tool "${t.name}": ${ep} requires [${t.policy?.[ep] ?? "\u2014"}] in code, [${d.requires?.[ep] ?? "\u2014"}] in manifest`);
129
+ }
130
+ }
131
+ }
132
+ for (const name of declared.keys()) problems.push(`tool "${name}" is declared in blu.module.json but not in code`);
133
+ return problems;
134
+ }
135
+ function buildRoutePath(route, params = {}) {
136
+ let ok = true;
137
+ const path = route.path.replace(/:([a-zA-Z][a-zA-Z0-9]*)/g, (_, name) => {
138
+ const v = params[name];
139
+ if (v === void 0 || v === null || v === "") ok = false;
140
+ return encodeURIComponent(String(v ?? ""));
141
+ });
142
+ return ok ? path : null;
143
+ }
144
+
145
+ // src/server/platform.js
146
+ var REGISTRY_URL = "https://registry.blu-ce.horizonmedia.com";
147
+ var STAGES2 = ["dev", "staging", "prod"];
148
+ var prefix = (stage) => {
149
+ if (!STAGES2.includes(stage)) throw new Error(`Unknown Blu environment "${stage}" (expected ${STAGES2.join(", ")})`);
150
+ return stage === "prod" ? "" : `${stage}.`;
151
+ };
152
+ var bluShellOrigins = (stage) => [`https://${prefix(stage)}blu-ce.horizonmedia.com`];
153
+ var bluIssuer = (stage) => `https://api.${prefix(stage)}blu-ce.horizonmedia.com`;
154
+
155
+ export {
156
+ isLocalUrl,
157
+ validateModuleManifest,
158
+ diffToolsAgainstManifest,
159
+ buildRoutePath,
160
+ REGISTRY_URL,
161
+ bluShellOrigins,
162
+ bluIssuer
163
+ };