@zhin.js/host-http 1.0.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.
package/lib/openapi.js ADDED
@@ -0,0 +1,136 @@
1
+ export function patternToOpenApiPath(pattern) {
2
+ return pattern
3
+ .replace(/\/\*$/u, '/{path}')
4
+ .replace(/:([A-Za-z0-9_]+)\+/gu, '{$1}')
5
+ .replace(/\*([A-Za-z0-9_]+)/gu, '{$1}')
6
+ .replace(/:([A-Za-z0-9_]+)/gu, '{$1}');
7
+ }
8
+ export function routeRequiresBearerAuth(pathname, apiBase) {
9
+ if (pathname.startsWith('/pub/') || pathname === '/pub')
10
+ return false;
11
+ if (pathname.includes('/webhook'))
12
+ return false;
13
+ if (!pathname.startsWith(`${apiBase}/`) && pathname !== apiBase)
14
+ return false;
15
+ return true;
16
+ }
17
+ function defaultOperationId(method, pattern) {
18
+ const slug = pattern
19
+ .replace(/^\//u, '')
20
+ .replace(/[{}]/gu, '')
21
+ .replace(/[/:*]/gu, '_')
22
+ .replace(/[^a-zA-Z0-9_]/gu, '_')
23
+ .replace(/_+/gu, '_')
24
+ .replace(/^_|_$/gu, '');
25
+ return `${method.toLowerCase()}_${slug || 'root'}`;
26
+ }
27
+ function inferTag(pattern, apiBase) {
28
+ const rest = pattern.startsWith(`${apiBase}/`)
29
+ ? pattern.slice(apiBase.length + 1)
30
+ : pattern.replace(/^\//u, '');
31
+ const seg = rest.split('/')[0];
32
+ if (!seg || seg === 'pub')
33
+ return undefined;
34
+ if (seg === 'console')
35
+ return 'console';
36
+ return seg;
37
+ }
38
+ function buildOperation(route, apiBase) {
39
+ const { method, pattern, meta } = route;
40
+ const op = {
41
+ operationId: meta?.operationId ?? defaultOperationId(method, pattern),
42
+ summary: meta?.summary ?? `${method} ${pattern}`,
43
+ responses: {
44
+ '200': {
45
+ description: 'Success',
46
+ content: {
47
+ 'application/json': {
48
+ schema: { type: 'object', additionalProperties: true },
49
+ },
50
+ },
51
+ },
52
+ },
53
+ };
54
+ if (meta?.description)
55
+ op.description = meta.description;
56
+ if (meta?.deprecated)
57
+ op.deprecated = true;
58
+ const tags = meta?.tags?.length
59
+ ? [...meta.tags]
60
+ : [inferTag(pattern, apiBase)].filter(Boolean);
61
+ if (tags.length)
62
+ op.tags = tags;
63
+ if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
64
+ op.requestBody = {
65
+ content: {
66
+ 'application/json': {
67
+ schema: { type: 'object', additionalProperties: true },
68
+ },
69
+ },
70
+ };
71
+ }
72
+ const pathParams = [...pattern.matchAll(/:([A-Za-z0-9_]+)/gu)].map((match) => ({
73
+ name: match[1],
74
+ in: 'path',
75
+ required: true,
76
+ schema: { type: 'string' },
77
+ }));
78
+ if (pattern.endsWith('/*')) {
79
+ pathParams.push({
80
+ name: 'path',
81
+ in: 'path',
82
+ required: true,
83
+ schema: { type: 'string' },
84
+ });
85
+ }
86
+ const allParams = [...pathParams, ...(meta?.parameters ?? [])];
87
+ if (allParams.length)
88
+ op.parameters = allParams;
89
+ if (meta?.responses)
90
+ op.responses = meta.responses;
91
+ if (meta?.requestBody)
92
+ op.requestBody = meta.requestBody;
93
+ if (routeRequiresBearerAuth(pattern.replace(/\/\*$/u, ''), apiBase)
94
+ || routeRequiresBearerAuth(pattern, apiBase)) {
95
+ op.security = [{ bearerAuth: [] }];
96
+ }
97
+ return op;
98
+ }
99
+ export function buildOpenApiDocument(routes, options = {}) {
100
+ const apiBase = options.apiBase ?? '/api';
101
+ const paths = {};
102
+ const seen = new Set();
103
+ const sorted = [...routes].sort((left, right) => left.pattern.localeCompare(right.pattern)
104
+ || left.method.localeCompare(right.method));
105
+ for (const route of sorted) {
106
+ const key = `${route.method}\0${route.pattern}`;
107
+ if (seen.has(key))
108
+ continue;
109
+ seen.add(key);
110
+ const oaPath = patternToOpenApiPath(route.pattern);
111
+ if (!paths[oaPath])
112
+ paths[oaPath] = {};
113
+ paths[oaPath][route.method.toLowerCase()] = buildOperation(route, apiBase);
114
+ }
115
+ const doc = {
116
+ openapi: '3.1.0',
117
+ info: {
118
+ title: options.title ?? 'Zhin Plugin Runtime Host',
119
+ version: options.version ?? '0.0.0',
120
+ description: 'Runtime route catalog for @zhin.js/host-http. Register routes via HttpHost.route().',
121
+ },
122
+ paths,
123
+ components: {
124
+ securitySchemes: {
125
+ bearerAuth: {
126
+ type: 'http',
127
+ scheme: 'bearer',
128
+ description: 'Set `Authorization: Bearer <http.token>` for paths under the API base.',
129
+ },
130
+ },
131
+ },
132
+ };
133
+ if (options.serverUrl)
134
+ doc.servers = [{ url: options.serverUrl }];
135
+ return doc;
136
+ }
@@ -0,0 +1,20 @@
1
+ /** Align with Remote Console file manager (paths relative to project root). */
2
+ export declare const FILE_MANAGER_ALLOWED: readonly string[];
3
+ export declare const FILE_MANAGER_BLOCKED: Readonly<Set<string>>;
4
+ export type FileTreeNode = {
5
+ readonly name: string;
6
+ readonly path: string;
7
+ readonly type: 'file' | 'directory';
8
+ readonly children?: readonly FileTreeNode[];
9
+ };
10
+ export declare function isProjectPathAllowed(relativePath: string): boolean;
11
+ export declare function buildProjectFileTree(projectRoot: string): Promise<FileTreeNode[]>;
12
+ export declare function readProjectFile(projectRoot: string, relativePath: string): Promise<{
13
+ content: string;
14
+ size: number;
15
+ }>;
16
+ export declare function saveProjectFile(projectRoot: string, relativePath: string, content: string): Promise<void>;
17
+ export declare function listEnvFiles(projectRoot: string): Promise<readonly {
18
+ name: string;
19
+ exists: boolean;
20
+ }[]>;
@@ -0,0 +1,145 @@
1
+ import { access, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ /** Align with Remote Console file manager (paths relative to project root). */
4
+ export const FILE_MANAGER_ALLOWED = Object.freeze([
5
+ 'src',
6
+ 'plugins',
7
+ 'client',
8
+ 'package.json',
9
+ 'tsconfig.json',
10
+ 'zhin.config.yml',
11
+ 'config.yml',
12
+ 'config.yaml',
13
+ 'config.json',
14
+ '.env',
15
+ '.env.development',
16
+ '.env.production',
17
+ 'README.md',
18
+ ]);
19
+ export const FILE_MANAGER_BLOCKED = Object.freeze(new Set([
20
+ 'node_modules',
21
+ '.git',
22
+ '.env.local',
23
+ 'data',
24
+ 'lib',
25
+ 'dist',
26
+ 'coverage',
27
+ ]));
28
+ export function isProjectPathAllowed(relativePath) {
29
+ if (relativePath.includes('..') || path.isAbsolute(relativePath))
30
+ return false;
31
+ const normalized = relativePath.replace(/\\/gu, '/').replace(/^\.\//u, '');
32
+ const firstSegment = normalized.split('/')[0] ?? '';
33
+ if (FILE_MANAGER_BLOCKED.has(firstSegment))
34
+ return false;
35
+ return FILE_MANAGER_ALLOWED.some((allowed) => normalized === allowed || normalized.startsWith(`${allowed}/`));
36
+ }
37
+ export async function buildProjectFileTree(projectRoot) {
38
+ const tree = [];
39
+ for (const entry of FILE_MANAGER_ALLOWED) {
40
+ if (entry.includes('/'))
41
+ continue;
42
+ const absPath = path.resolve(projectRoot, entry);
43
+ if (!(await pathExists(absPath)))
44
+ continue;
45
+ const info = await safeStat(absPath);
46
+ if (!info)
47
+ continue;
48
+ if (info.isDirectory()) {
49
+ tree.push({
50
+ name: entry,
51
+ path: entry,
52
+ type: 'directory',
53
+ children: await buildDirectoryTree(projectRoot, entry, 3),
54
+ });
55
+ }
56
+ else if (info.isFile()) {
57
+ tree.push({ name: entry, path: entry, type: 'file' });
58
+ }
59
+ }
60
+ return tree.sort(compareTreeNodes);
61
+ }
62
+ export async function readProjectFile(projectRoot, relativePath) {
63
+ const absPath = resolveAllowedPath(projectRoot, relativePath);
64
+ if (!absPath)
65
+ throw new Error(`Access denied: ${relativePath}`);
66
+ if (!(await pathExists(absPath)))
67
+ throw new Error(`File not found: ${relativePath}`);
68
+ const info = await safeStat(absPath);
69
+ if (!info?.isFile())
70
+ throw new Error(`Not a file: ${relativePath}`);
71
+ if (info.size > 1024 * 1024) {
72
+ throw new Error(`File too large: ${(info.size / 1024).toFixed(0)}KB (max 1MB)`);
73
+ }
74
+ const content = await readFile(absPath, 'utf8');
75
+ return { content, size: info.size };
76
+ }
77
+ export async function saveProjectFile(projectRoot, relativePath, content) {
78
+ const absPath = resolveAllowedPath(projectRoot, relativePath);
79
+ if (!absPath)
80
+ throw new Error(`Access denied: ${relativePath}`);
81
+ await mkdir(path.dirname(absPath), { recursive: true });
82
+ await writeFile(absPath, content, 'utf8');
83
+ }
84
+ export async function listEnvFiles(projectRoot) {
85
+ const names = ['.env', '.env.development', '.env.production'];
86
+ const result = await Promise.all(names.map(async (name) => ({
87
+ name,
88
+ exists: await pathExists(path.resolve(projectRoot, name)),
89
+ })));
90
+ return Object.freeze(result);
91
+ }
92
+ function resolveAllowedPath(projectRoot, relativePath) {
93
+ if (!isProjectPathAllowed(relativePath))
94
+ return null;
95
+ return path.resolve(projectRoot, relativePath);
96
+ }
97
+ async function buildDirectoryTree(projectRoot, relativePath, maxDepth) {
98
+ if (maxDepth <= 0)
99
+ return [];
100
+ const absDir = path.resolve(projectRoot, relativePath);
101
+ const info = await safeStat(absDir);
102
+ if (!info?.isDirectory())
103
+ return [];
104
+ const entries = await readdir(absDir, { withFileTypes: true });
105
+ const result = [];
106
+ for (const entry of entries) {
107
+ if (FILE_MANAGER_BLOCKED.has(entry.name) || entry.name.startsWith('.'))
108
+ continue;
109
+ const childRelative = relativePath ? `${relativePath}/${entry.name}` : entry.name;
110
+ if (entry.isDirectory()) {
111
+ result.push({
112
+ name: entry.name,
113
+ path: childRelative,
114
+ type: 'directory',
115
+ children: await buildDirectoryTree(projectRoot, childRelative, maxDepth - 1),
116
+ });
117
+ }
118
+ else if (entry.isFile()) {
119
+ result.push({ name: entry.name, path: childRelative, type: 'file' });
120
+ }
121
+ }
122
+ return result.sort(compareTreeNodes);
123
+ }
124
+ function compareTreeNodes(a, b) {
125
+ if (a.type !== b.type)
126
+ return a.type === 'directory' ? -1 : 1;
127
+ return a.name.localeCompare(b.name);
128
+ }
129
+ async function pathExists(filePath) {
130
+ try {
131
+ await access(filePath);
132
+ return true;
133
+ }
134
+ catch {
135
+ return false;
136
+ }
137
+ }
138
+ async function safeStat(filePath) {
139
+ try {
140
+ return await stat(filePath);
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ }
@@ -0,0 +1,2 @@
1
+ /** Constant-time string compare without Node `crypto` (portable). */
2
+ export declare function timingSafeEqualString(a: string, b: string): boolean;
@@ -0,0 +1,10 @@
1
+ /** Constant-time string compare without Node `crypto` (portable). */
2
+ export function timingSafeEqualString(a, b) {
3
+ if (a.length !== b.length)
4
+ return false;
5
+ let out = 0;
6
+ for (let i = 0; i < a.length; i += 1) {
7
+ out |= a.charCodeAt(i) ^ b.charCodeAt(i);
8
+ }
9
+ return out === 0;
10
+ }
@@ -0,0 +1,20 @@
1
+ /** Demo Host token scope (ADR 0016 subset for Plugin Runtime Host). */
2
+ export type AuthScope = 'full' | 'demo';
3
+ export type ScopedTokenConfig = {
4
+ readonly token: string;
5
+ readonly scope: AuthScope;
6
+ };
7
+ export type TokenRegistryConfig = {
8
+ readonly primaryToken?: string;
9
+ readonly scopedTokens?: readonly ScopedTokenConfig[];
10
+ };
11
+ export declare class TokenRegistry {
12
+ #private;
13
+ constructor(config?: TokenRegistryConfig);
14
+ resolve(token: string): AuthScope | null;
15
+ hasAnyToken(): boolean;
16
+ primaryTokenPrefixForLog(): string;
17
+ }
18
+ /** WebSocket upgrade paths allowed for demo scope. */
19
+ export declare function isDemoWebSocketPath(pathname: string): boolean;
20
+ export declare function extractBearerToken(authorization: string | undefined, queryToken: string | null): string;
@@ -0,0 +1,41 @@
1
+ import { timingSafeEqualString } from './timing-safe-equal.js';
2
+ export class TokenRegistry {
3
+ #entries = new Map();
4
+ constructor(config = {}) {
5
+ if (config.primaryToken)
6
+ this.#entries.set(config.primaryToken, 'full');
7
+ for (const { token, scope } of config.scopedTokens ?? []) {
8
+ if (token)
9
+ this.#entries.set(token, scope);
10
+ }
11
+ }
12
+ resolve(token) {
13
+ if (!token)
14
+ return null;
15
+ for (const [known, scope] of this.#entries) {
16
+ if (timingSafeEqualString(known, token))
17
+ return scope;
18
+ }
19
+ return null;
20
+ }
21
+ hasAnyToken() {
22
+ return this.#entries.size > 0;
23
+ }
24
+ primaryTokenPrefixForLog() {
25
+ for (const [tok, scope] of this.#entries) {
26
+ if (scope === 'full')
27
+ return tok.slice(0, 6);
28
+ }
29
+ const first = this.#entries.keys().next().value;
30
+ return first ? first.slice(0, 6) : '';
31
+ }
32
+ }
33
+ /** WebSocket upgrade paths allowed for demo scope. */
34
+ export function isDemoWebSocketPath(pathname) {
35
+ return pathname === '/sandbox';
36
+ }
37
+ export function extractBearerToken(authorization, queryToken) {
38
+ if (authorization?.startsWith('Bearer '))
39
+ return authorization.slice(7).trim();
40
+ return queryToken?.trim() ?? '';
41
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@zhin.js/host-http",
3
+ "version": "1.0.0",
4
+ "description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "types": "./lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "development": "./src/index.ts",
12
+ "import": "./lib/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "lib",
17
+ "src"
18
+ ],
19
+ "dependencies": {
20
+ "ws": "^8.21.0",
21
+ "@zhin.js/logger": "1.0.74",
22
+ "@zhin.js/plugin-runtime": "1.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^26.1.0",
26
+ "@types/ws": "^8.18.1",
27
+ "typescript": "^6.0.3"
28
+ },
29
+ "engines": {
30
+ "node": "^20.19.0 || >=22.12.0"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/zhinjs/zhin.git",
35
+ "directory": "packages/host/http"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "registry": "https://registry.npmjs.org"
40
+ },
41
+ "license": "MIT",
42
+ "private": false,
43
+ "scripts": {
44
+ "build": "tsc",
45
+ "clean": "rimraf lib",
46
+ "test": "vitest run --root ../../.. packages/host/http/tests"
47
+ }
48
+ }