@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 +134 -0
- package/dist/auth.d.ts +6 -0
- package/dist/auth.js +65 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +159 -0
- package/package.json +36 -0
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
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
|
+
}
|