@workopilot/pluginapi-dev-host 0.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.
package/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # @workopilot/pluginapi-dev-host
2
+
3
+ Workopilot Plugin API Dev Host is a local development server for third-party plugin projects.
4
+
5
+ It simulates the Workopilot host application during development: it loads a plugin manifest, imports plugin handlers, injects database access from `.env.local`, parses the JWT payload from `Authorization: Bearer <token>`, injects `ctx.currentUser`, and exposes plugin routes locally.
6
+
7
+ It is intended for local development only. Production plugin loading, JWT signature verification, Redis token validation, and plugin package installation are handled by the real host application.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ npm install --save-dev @workopilot/pluginapi-dev-host
13
+ ```
14
+
15
+ Plugin projects should also install the SDK:
16
+
17
+ ```bash
18
+ npm install @workopilot/pluginapi-sdk
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ Add a development script in the plugin project's `package.json`:
24
+
25
+ ```json
26
+ {
27
+ "scripts": {
28
+ "dev": "npm run build && plugin-dev-host --plugin . --env .env.local --port 3100"
29
+ }
30
+ }
31
+ ```
32
+
33
+ Start local development:
34
+
35
+ ```bash
36
+ npm run dev
37
+ ```
38
+
39
+ Then request plugin routes through the local dev host:
40
+
41
+ ```bash
42
+ curl -H "Authorization: Bearer <jwt-token>" http://127.0.0.1:3100/plugins/my-plugin/me
43
+ ```
44
+
45
+ The plugin name comes from `plugin.json`:
46
+
47
+ ```json
48
+ {
49
+ "name": "my-plugin",
50
+ "entry": "dist/index.js",
51
+ "routes": [
52
+ {
53
+ "method": "GET",
54
+ "path": "/me",
55
+ "handler": "getCurrentUser"
56
+ }
57
+ ]
58
+ }
59
+ ```
60
+
61
+ The final route format is:
62
+
63
+ ```text
64
+ /plugins/<plugin-name>/<route-path>
65
+ ```
66
+
67
+ ## Configuration
68
+
69
+ The dev host requires a database env file. By default, pass `.env.local`:
70
+
71
+ ```bash
72
+ plugin-dev-host --plugin . --env .env.local --port 3100
73
+ ```
74
+
75
+ Example `.env.local`:
76
+
77
+ ```env
78
+ DB_HOST=127.0.0.1
79
+ DB_NAME=plugin_dev
80
+ DB_USER=root
81
+ DB_PASSWORD=password
82
+ DB_PORT=3306
83
+ DB_SSL=false
84
+ DB_CHARSET=utf8
85
+ ```
86
+
87
+ If the env file cannot be found, the dev host fails to start. It does not include default database credentials.
88
+
89
+ ## Provided Capabilities
90
+
91
+ ### Database
92
+
93
+ The dev host injects:
94
+
95
+ ```ts
96
+ ctx.database.query(sql, params)
97
+ ctx.database.insert(sql, params)
98
+ ctx.database.update(sql, params)
99
+ ctx.database.delete(sql, params)
100
+ ```
101
+
102
+ ### Current User
103
+
104
+ Every plugin route request must include:
105
+
106
+ ```text
107
+ Authorization: Bearer <jwt-token>
108
+ ```
109
+
110
+ For local development, the dev host only parses the JWT payload and maps it to:
111
+
112
+ ```ts
113
+ ctx.currentUser
114
+ ```
115
+
116
+ It does not verify the JWT signature, does not check Redis, and does not validate token lifetime.
117
+
118
+ ### Logger
119
+
120
+ The dev host injects:
121
+
122
+ ```ts
123
+ ctx.logger.info(message, data)
124
+ ctx.logger.error(message, data)
125
+ ```
126
+
127
+ ## CLI Options
128
+
129
+ ```text
130
+ --plugin Plugin project directory. Default: .
131
+ --env Database env file. Default: .env.local
132
+ --host Listen host. Default: 127.0.0.1
133
+ --port Listen port. Default: 3100
134
+ ```
package/dist/auth.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { FastifyRequest } from 'fastify';
2
+ import type { CurrentUser } from '@workopilot/pluginapi-sdk';
3
+ export declare class DevAuthError extends Error {
4
+ constructor(message: string);
5
+ }
6
+ export declare function authenticateDevRequest(request: FastifyRequest): CurrentUser;
package/dist/auth.js ADDED
@@ -0,0 +1,65 @@
1
+ export class DevAuthError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = 'DevAuthError';
5
+ }
6
+ }
7
+ export function authenticateDevRequest(request) {
8
+ const token = readBearerToken(request.headers.authorization);
9
+ const payload = parseJwtPayload(token);
10
+ return toCurrentUser(payload);
11
+ }
12
+ function readBearerToken(authorization) {
13
+ const value = Array.isArray(authorization) ? authorization[0] : authorization;
14
+ if (!value) {
15
+ throw new DevAuthError('Missing Authorization header.');
16
+ }
17
+ const match = /^Bearer\s+(.+)$/i.exec(value.trim());
18
+ if (!match) {
19
+ throw new DevAuthError('Authorization header must use Bearer token.');
20
+ }
21
+ return match[1];
22
+ }
23
+ function parseJwtPayload(token) {
24
+ const parts = token.split('.');
25
+ if (parts.length < 2) {
26
+ throw new DevAuthError('Invalid JWT format.');
27
+ }
28
+ try {
29
+ return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
30
+ }
31
+ catch {
32
+ throw new DevAuthError('Invalid JWT payload.');
33
+ }
34
+ }
35
+ function toCurrentUser(payload) {
36
+ const loginId = readRequiredString(payload.loginId, 'loginId');
37
+ const userId = loginId.split(':').at(-1);
38
+ if (!userId) {
39
+ throw new DevAuthError('JWT loginId does not contain a user id.');
40
+ }
41
+ return {
42
+ tenantId: readRequiredString(payload.tenantId, 'tenantId'),
43
+ userId,
44
+ clientid: readRequiredString(payload.clientid, 'clientid'),
45
+ userName: readRequiredString(payload.userName, 'userName'),
46
+ deptId: readOptionalStringOrNumber(payload.deptId),
47
+ deptName: readOptionalString(payload.deptName),
48
+ deptCategory: readOptionalString(payload.deptCategory)
49
+ };
50
+ }
51
+ function readRequiredString(value, fieldName) {
52
+ if (typeof value !== 'string' || value.length === 0) {
53
+ throw new DevAuthError(`JWT payload field "${fieldName}" is required.`);
54
+ }
55
+ return value;
56
+ }
57
+ function readOptionalString(value) {
58
+ return typeof value === 'string' ? value : '';
59
+ }
60
+ function readOptionalStringOrNumber(value) {
61
+ if (typeof value === 'string' || typeof value === 'number') {
62
+ return value;
63
+ }
64
+ return null;
65
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ import Fastify from 'fastify';
3
+ import { config as loadEnvFile } from 'dotenv';
4
+ import mysql from 'mysql2/promise';
5
+ import { existsSync } from 'node:fs';
6
+ import { readFile } from 'node:fs/promises';
7
+ import { resolve } from 'node:path';
8
+ import { pathToFileURL } from 'node:url';
9
+ import { DevAuthError, authenticateDevRequest } from './auth.js';
10
+ function readOptions(argv) {
11
+ const options = {};
12
+ for (let index = 0; index < argv.length; index += 2) {
13
+ const key = argv[index];
14
+ const value = argv[index + 1];
15
+ if (!key?.startsWith('--') || !value) {
16
+ throw new Error('Usage: plugin-dev-host --plugin . --env .env.local --port 3100');
17
+ }
18
+ options[key.slice(2)] = value;
19
+ }
20
+ return {
21
+ pluginDir: resolve(options.plugin ?? '.'),
22
+ envFile: resolve(options.env ?? '.env.local'),
23
+ host: options.host ?? '127.0.0.1',
24
+ port: Number(options.port ?? 3100)
25
+ };
26
+ }
27
+ function loadDatabaseEnv(envFile) {
28
+ if (!existsSync(envFile)) {
29
+ throw new Error(`Database env file was not found: ${envFile}`);
30
+ }
31
+ loadEnvFile({ path: envFile });
32
+ return { source: envFile };
33
+ }
34
+ function createDatabaseCapability() {
35
+ const requiredKeys = ['DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASSWORD'];
36
+ for (const key of requiredKeys) {
37
+ if (!process.env[key]) {
38
+ throw new Error(`Missing required environment variable: ${key}`);
39
+ }
40
+ }
41
+ const pool = mysql.createPool({
42
+ host: process.env.DB_HOST,
43
+ database: process.env.DB_NAME,
44
+ user: process.env.DB_USER,
45
+ password: process.env.DB_PASSWORD,
46
+ port: Number(process.env.DB_PORT ?? 3306),
47
+ charset: process.env.DB_CHARSET ?? 'utf8',
48
+ ssl: process.env.DB_SSL === 'true' ? {} : undefined,
49
+ waitForConnections: true,
50
+ connectionLimit: 5
51
+ });
52
+ return {
53
+ async query(sql, params = []) {
54
+ const [rows] = await pool.query(sql, params);
55
+ return rows;
56
+ },
57
+ async insert(sql, params = []) {
58
+ return executeWrite(sql, params);
59
+ },
60
+ async update(sql, params = []) {
61
+ return executeWrite(sql, params);
62
+ },
63
+ async delete(sql, params = []) {
64
+ return executeWrite(sql, params);
65
+ },
66
+ async close() {
67
+ await pool.end();
68
+ }
69
+ };
70
+ async function executeWrite(sql, params) {
71
+ const [result] = await pool.execute(sql, params);
72
+ return {
73
+ affectedRows: result.affectedRows,
74
+ insertId: result.insertId,
75
+ warningStatus: result.warningStatus,
76
+ changedRows: result.changedRows,
77
+ info: result.info
78
+ };
79
+ }
80
+ }
81
+ async function loadManifest(pluginDir) {
82
+ const content = await readFile(resolve(pluginDir, 'plugin.json'), 'utf8');
83
+ return JSON.parse(content);
84
+ }
85
+ async function loadHandlers(pluginDir, manifest) {
86
+ const modulePath = resolve(pluginDir, manifest.entry);
87
+ const moduleUrl = `${pathToFileURL(modulePath).href}?v=${Date.now()}`;
88
+ const pluginModule = (await import(moduleUrl));
89
+ const handlers = {};
90
+ for (const route of manifest.routes) {
91
+ const handler = pluginModule[route.handler];
92
+ if (typeof handler !== 'function') {
93
+ throw new Error(`Plugin does not export handler "${route.handler}".`);
94
+ }
95
+ handlers[route.handler] = handler;
96
+ }
97
+ return handlers;
98
+ }
99
+ function normalizePath(path) {
100
+ if (!path || path === '/') {
101
+ return '/';
102
+ }
103
+ return path.endsWith('/') ? path.slice(0, -1) : path;
104
+ }
105
+ const options = readOptions(process.argv.slice(2));
106
+ const databaseEnv = loadDatabaseEnv(options.envFile);
107
+ const manifest = await loadManifest(options.pluginDir);
108
+ const handlers = await loadHandlers(options.pluginDir, manifest);
109
+ const database = createDatabaseCapability();
110
+ const app = Fastify({
111
+ logger: true
112
+ });
113
+ app.log.info({ source: databaseEnv.source }, 'plugin dev host database configuration loaded');
114
+ app.addHook('onClose', async () => {
115
+ await database.close();
116
+ });
117
+ app.get('/health', async () => ({
118
+ ok: true,
119
+ service: 'plugin-dev-host',
120
+ plugin: manifest.name
121
+ }));
122
+ app.all('/plugins/:pluginName/*', async (request, reply) => {
123
+ try {
124
+ const currentUser = authenticateDevRequest(request);
125
+ const params = request.params;
126
+ if (params.pluginName !== manifest.name) {
127
+ return reply.code(404).send({ message: `Dev host loaded plugin "${manifest.name}".` });
128
+ }
129
+ const pluginPath = `/${params['*'] ?? ''}`;
130
+ const route = manifest.routes.find((candidate) => candidate.method === request.method && normalizePath(candidate.path) === normalizePath(pluginPath));
131
+ if (!route) {
132
+ return reply.code(404).send({ message: `Route ${request.method} /plugins/${manifest.name}${pluginPath} is not registered.` });
133
+ }
134
+ const ctx = {
135
+ database,
136
+ currentUser,
137
+ logger: {
138
+ info: (message, data) => app.log.info({ plugin: manifest.name, data }, message),
139
+ error: (message, data) => app.log.error({ plugin: manifest.name, data }, message)
140
+ }
141
+ };
142
+ const pluginRequest = {
143
+ method: request.method,
144
+ url: request.url,
145
+ query: request.query,
146
+ body: request.body,
147
+ headers: request.headers
148
+ };
149
+ const result = await handlers[route.handler]({ request: pluginRequest, ctx });
150
+ return reply.send(result);
151
+ }
152
+ catch (error) {
153
+ if (error instanceof DevAuthError) {
154
+ return reply.code(401).send({ message: error.message });
155
+ }
156
+ throw error;
157
+ }
158
+ });
159
+ await app.listen({ host: options.host, port: options.port });
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@workopilot/pluginapi-dev-host",
3
+ "version": "0.1.0",
4
+ "description": "Workopilot plugin API local development host",
5
+ "keywords": [
6
+ "wiseai",
7
+ "workopilot",
8
+ "plugin",
9
+ "dev-host"
10
+ ],
11
+ "author": "workopilot",
12
+ "license": "MIT",
13
+ "type": "module",
14
+ "bin": {
15
+ "plugin-dev-host": "dist/cli.js"
16
+ },
17
+ "main": "dist/cli.js",
18
+ "types": "dist/cli.d.ts",
19
+ "files": [
20
+ "dist",
21
+ "README.md"
22
+ ],
23
+ "scripts": {
24
+ "build": "tsc -p tsconfig.json"
25
+ },
26
+ "dependencies": {
27
+ "@workopilot/pluginapi-sdk": "0.1.0",
28
+ "dotenv": "^16.4.7",
29
+ "fastify": "^5.2.1",
30
+ "mysql2": "^3.12.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^22.10.2",
34
+ "typescript": "^5.7.2"
35
+ }
36
+ }