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