script-runner-kit 0.1.0 → 0.1.1
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 +18 -5
- package/docs/AUTHENTICATION.md +57 -8
- package/package.json +1 -1
- package/script-runner.config.json +9 -3
- package/src/config.js +48 -16
- package/src/server.js +59 -5
package/README.md
CHANGED
|
@@ -58,12 +58,18 @@ Example `script-runner.config.json`:
|
|
|
58
58
|
{
|
|
59
59
|
"port": 8088,
|
|
60
60
|
"auditDir": ".script-audit-logs",
|
|
61
|
-
"
|
|
61
|
+
"akSk": [
|
|
62
|
+
{ "ak": "global-bot-v2", "sk": "global-secret-a" },
|
|
63
|
+
{ "ak": "global-bot-v1", "sk": "global-secret-b" }
|
|
64
|
+
],
|
|
62
65
|
"scripts": {
|
|
63
66
|
"check-update": {
|
|
64
67
|
"scriptPath": "./scripts/check-update.sh",
|
|
65
68
|
"rootDir": ".",
|
|
66
|
-
"
|
|
69
|
+
"akSk": [
|
|
70
|
+
{ "ak": "check-bot-v2", "sk": "check-secret-a" },
|
|
71
|
+
{ "ak": "check-bot-v1", "sk": "check-secret-b" }
|
|
72
|
+
]
|
|
67
73
|
}
|
|
68
74
|
}
|
|
69
75
|
}
|
|
@@ -75,7 +81,7 @@ Notes:
|
|
|
75
81
|
- Each script supports either:
|
|
76
82
|
- `scriptPath` (execute via `bash <scriptPath>`)
|
|
77
83
|
- or `command` + optional `args`.
|
|
78
|
-
- Auth uses JWT and supports
|
|
84
|
+
- Auth uses JWT and supports AK/SK pairs per script via `akSk`.
|
|
79
85
|
|
|
80
86
|
### Auto package.json scripts
|
|
81
87
|
|
|
@@ -94,11 +100,18 @@ Every API call requires a JWT token. Supported token sources:
|
|
|
94
100
|
- `x-runner-token: <token>`
|
|
95
101
|
- query parameter `?token=<token>` (convenient for EventSource demos)
|
|
96
102
|
|
|
103
|
+
Generate your JWT at `https://jwt.io` using:
|
|
104
|
+
|
|
105
|
+
- Header: `{"alg":"HS256","typ":"JWT"}`
|
|
106
|
+
- Payload example: `{"sub":"gitai","ak":"check-bot-v2","script":"check-update"}`
|
|
107
|
+
- Secret: use the SK mapped to that AK in your config
|
|
108
|
+
|
|
97
109
|
Verification rules:
|
|
98
110
|
|
|
99
111
|
- Uses `jsonwebtoken.verify()` with algorithms `HS256/HS384/HS512`
|
|
100
|
-
- Supports multiple
|
|
101
|
-
- If a script has no local `
|
|
112
|
+
- Supports multiple credentials per script (`akSk` array), useful for key rotation
|
|
113
|
+
- If a script has no local `akSk`, top-level `akSk` is used as fallback
|
|
114
|
+
- Legacy `authTokens` is still accepted for backward compatibility
|
|
102
115
|
|
|
103
116
|
## HTTP API
|
|
104
117
|
|
package/docs/AUTHENTICATION.md
CHANGED
|
@@ -10,16 +10,22 @@ Supported sources (priority order):
|
|
|
10
10
|
2. `x-runner-token: <token>`
|
|
11
11
|
3. Query param `?token=<token>`
|
|
12
12
|
|
|
13
|
-
## Config example
|
|
13
|
+
## Config example (AK/SK)
|
|
14
14
|
|
|
15
15
|
```json
|
|
16
16
|
{
|
|
17
|
-
"
|
|
17
|
+
"akSk": [
|
|
18
|
+
{ "ak": "global-bot-v2", "sk": "global-secret-a" },
|
|
19
|
+
{ "ak": "global-bot-v1", "sk": "global-secret-b" }
|
|
20
|
+
],
|
|
18
21
|
"scripts": {
|
|
19
22
|
"deploy": {
|
|
20
23
|
"command": "npm",
|
|
21
24
|
"args": ["run", "deploy"],
|
|
22
|
-
"
|
|
25
|
+
"akSk": [
|
|
26
|
+
{ "ak": "deploy-bot-v2", "sk": "deploy-secret-v2" },
|
|
27
|
+
{ "ak": "deploy-bot-v1", "sk": "deploy-secret-v1" }
|
|
28
|
+
]
|
|
23
29
|
},
|
|
24
30
|
"build": {
|
|
25
31
|
"command": "npm",
|
|
@@ -29,15 +35,58 @@ Supported sources (priority order):
|
|
|
29
35
|
}
|
|
30
36
|
```
|
|
31
37
|
|
|
32
|
-
- Script-level `
|
|
33
|
-
- If missing, top-level `
|
|
38
|
+
- Script-level `akSk` are preferred.
|
|
39
|
+
- If missing, top-level `akSk` is used.
|
|
40
|
+
|
|
41
|
+
> Backward compatibility: legacy `authTokens` (string array) is still supported.
|
|
34
42
|
|
|
35
43
|
## Key rotation
|
|
36
44
|
|
|
37
|
-
Use multiple
|
|
45
|
+
Use multiple AK/SK pairs and keep new credential first:
|
|
38
46
|
|
|
39
47
|
```json
|
|
40
|
-
"
|
|
48
|
+
"akSk": [
|
|
49
|
+
{ "ak": "deploy-bot-v3", "sk": "new-secret" },
|
|
50
|
+
{ "ak": "deploy-bot-v2", "sk": "old-secret" }
|
|
51
|
+
]
|
|
41
52
|
```
|
|
42
53
|
|
|
43
|
-
The runner tries
|
|
54
|
+
The runner tries matching AK first (from JWT claim `ak`), then falls back to all SKs.
|
|
55
|
+
|
|
56
|
+
## Generate token (jwt.io)
|
|
57
|
+
|
|
58
|
+
Use `https://jwt.io` and sign with HS256.
|
|
59
|
+
|
|
60
|
+
- Header:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{ "alg": "HS256", "typ": "JWT" }
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- Payload example:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{ "sub": "gitai", "ak": "deploy-bot-v2", "script": "deploy" }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Payload fields (recommended)
|
|
73
|
+
|
|
74
|
+
- `sub` (string): caller identity, for audit display (for example `gitai`)
|
|
75
|
+
- `ak` (string): AK name from your `akSk` config (for example `deploy-bot-v2`)
|
|
76
|
+
- `script` (string): target script name (for example `deploy`)
|
|
77
|
+
- `exp` (number, optional): UNIX timestamp expiration (or set via jwt.io expiration UI)
|
|
78
|
+
|
|
79
|
+
Example payload with expiration:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"sub": "gitai",
|
|
84
|
+
"ak": "deploy-bot-v2",
|
|
85
|
+
"script": "deploy",
|
|
86
|
+
"exp": 1893456000
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- Secret: use the SK corresponding to `deploy-bot-v2` in your `akSk` config.
|
|
91
|
+
|
|
92
|
+
Then send the generated JWT via `Authorization: Bearer <token>` (or `x-runner-token` / `?token=`).
|
package/package.json
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"port": 8088,
|
|
3
3
|
"auditDir": ".script-audit-logs",
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
"akSk": [
|
|
5
|
+
{
|
|
6
|
+
"ak": "global-bot-v2",
|
|
7
|
+
"sk": "replace-with-strong-jwt-secret-1"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"ak": "global-bot-v1",
|
|
11
|
+
"sk": "replace-with-strong-jwt-secret-2"
|
|
12
|
+
}
|
|
7
13
|
],
|
|
8
14
|
"scripts": {
|
|
9
15
|
"check-update": {
|
package/src/config.js
CHANGED
|
@@ -97,7 +97,7 @@ function loadJsConfig(configPath) {
|
|
|
97
97
|
return loaded;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
function
|
|
100
|
+
function normalizeLegacyAuthTokens(value, fieldPath) {
|
|
101
101
|
if (!Array.isArray(value)) {
|
|
102
102
|
throw new Error(`${fieldPath} must be an array of strings`);
|
|
103
103
|
}
|
|
@@ -105,7 +105,41 @@ function normalizeAuthTokens(value, fieldPath) {
|
|
|
105
105
|
if (tokens.length === 0) {
|
|
106
106
|
throw new Error(`${fieldPath} must contain at least one non-empty token`);
|
|
107
107
|
}
|
|
108
|
-
return tokens;
|
|
108
|
+
return tokens.map((sk, index) => ({ ak: `legacy-${index + 1}`, sk }));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeAkSk(value, fieldPath) {
|
|
112
|
+
if (!Array.isArray(value)) {
|
|
113
|
+
throw new Error(`${fieldPath} must be an array of { ak, sk } objects`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const entries = value.map((item, index) => {
|
|
117
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
118
|
+
throw new Error(`${fieldPath}[${index}] must be an object with ak and sk`);
|
|
119
|
+
}
|
|
120
|
+
const ak = String(item.ak || "").trim();
|
|
121
|
+
const sk = String(item.sk || "").trim();
|
|
122
|
+
if (!ak || !sk) {
|
|
123
|
+
throw new Error(`${fieldPath}[${index}] must provide non-empty ak and sk`);
|
|
124
|
+
}
|
|
125
|
+
return { ak, sk };
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (entries.length === 0) {
|
|
129
|
+
throw new Error(`${fieldPath} must contain at least one credential`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return entries;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readAkSkField(source, akSkFieldPath, legacyFieldPath) {
|
|
136
|
+
if (source.akSk !== undefined) {
|
|
137
|
+
return normalizeAkSk(source.akSk, akSkFieldPath);
|
|
138
|
+
}
|
|
139
|
+
if (source.authTokens !== undefined) {
|
|
140
|
+
return normalizeLegacyAuthTokens(source.authTokens, legacyFieldPath);
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
109
143
|
}
|
|
110
144
|
|
|
111
145
|
function normalizeScriptFromConfig(configDir, scriptName, scriptConfig) {
|
|
@@ -119,16 +153,17 @@ function normalizeScriptFromConfig(configDir, scriptName, scriptConfig) {
|
|
|
119
153
|
? scriptConfig.rootDir
|
|
120
154
|
: "."
|
|
121
155
|
);
|
|
122
|
-
const
|
|
123
|
-
scriptConfig
|
|
124
|
-
|
|
125
|
-
|
|
156
|
+
const akSk = readAkSkField(
|
|
157
|
+
scriptConfig,
|
|
158
|
+
`scripts.${scriptName}.akSk`,
|
|
159
|
+
`scripts.${scriptName}.authTokens`
|
|
160
|
+
);
|
|
126
161
|
|
|
127
162
|
if (typeof scriptConfig.scriptPath === "string" && scriptConfig.scriptPath.trim()) {
|
|
128
163
|
return {
|
|
129
164
|
rootDir: resolvedRootDir,
|
|
130
165
|
scriptPath: path.resolve(configDir, scriptConfig.scriptPath),
|
|
131
|
-
|
|
166
|
+
akSk,
|
|
132
167
|
};
|
|
133
168
|
}
|
|
134
169
|
|
|
@@ -146,7 +181,7 @@ function normalizeScriptFromConfig(configDir, scriptName, scriptConfig) {
|
|
|
146
181
|
rootDir: resolvedRootDir,
|
|
147
182
|
command: scriptConfig.command,
|
|
148
183
|
args,
|
|
149
|
-
|
|
184
|
+
akSk,
|
|
150
185
|
};
|
|
151
186
|
}
|
|
152
187
|
|
|
@@ -200,10 +235,7 @@ function resolveScriptConfig(configPath, rawConfig, cwd) {
|
|
|
200
235
|
}
|
|
201
236
|
|
|
202
237
|
const configDir = path.dirname(configPath);
|
|
203
|
-
const
|
|
204
|
-
rawConfig.authTokens === undefined
|
|
205
|
-
? undefined
|
|
206
|
-
: normalizeAuthTokens(rawConfig.authTokens, "authTokens");
|
|
238
|
+
const globalAkSk = readAkSkField(rawConfig, "akSk", "authTokens");
|
|
207
239
|
const scripts = rawConfig.scripts || {};
|
|
208
240
|
if (typeof scripts !== "object" || Array.isArray(scripts)) {
|
|
209
241
|
throw new Error("Config field 'scripts' must be an object when provided");
|
|
@@ -232,15 +264,15 @@ function resolveScriptConfig(configPath, rawConfig, cwd) {
|
|
|
232
264
|
}
|
|
233
265
|
|
|
234
266
|
for (const [scriptName, scriptConfig] of Object.entries(resolvedScripts)) {
|
|
235
|
-
if (!Array.isArray(scriptConfig.
|
|
236
|
-
if (
|
|
267
|
+
if (!Array.isArray(scriptConfig.akSk) || scriptConfig.akSk.length === 0) {
|
|
268
|
+
if (globalAkSk && globalAkSk.length > 0) {
|
|
237
269
|
resolvedScripts[scriptName] = {
|
|
238
270
|
...scriptConfig,
|
|
239
|
-
|
|
271
|
+
akSk: globalAkSk,
|
|
240
272
|
};
|
|
241
273
|
} else {
|
|
242
274
|
throw new Error(
|
|
243
|
-
`Auth required for scripts.${scriptName}. Add scripts.${scriptName}.authTokens or top-level
|
|
275
|
+
`Auth required for scripts.${scriptName}. Add scripts.${scriptName}.akSk (or legacy authTokens) or top-level akSk`
|
|
244
276
|
);
|
|
245
277
|
}
|
|
246
278
|
}
|
package/src/server.js
CHANGED
|
@@ -51,13 +51,24 @@ function readTokenFromRequest(req, url) {
|
|
|
51
51
|
return "";
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
function
|
|
55
|
-
|
|
54
|
+
function verifyJwtWithAkSk(token, akSkList) {
|
|
55
|
+
const decoded = jwt.decode(token);
|
|
56
|
+
const hintedAk =
|
|
57
|
+
decoded && typeof decoded === "object" && typeof decoded.ak === "string"
|
|
58
|
+
? decoded.ak.trim()
|
|
59
|
+
: "";
|
|
60
|
+
|
|
61
|
+
const preferred = hintedAk
|
|
62
|
+
? akSkList.filter((entry) => entry.ak === hintedAk)
|
|
63
|
+
: [];
|
|
64
|
+
const candidates = preferred.length > 0 ? preferred : akSkList;
|
|
65
|
+
|
|
66
|
+
for (const credential of candidates) {
|
|
56
67
|
try {
|
|
57
|
-
const payload = jwt.verify(token,
|
|
68
|
+
const payload = jwt.verify(token, credential.sk, {
|
|
58
69
|
algorithms: ["HS256", "HS384", "HS512"],
|
|
59
70
|
});
|
|
60
|
-
return { ok: true, payload };
|
|
71
|
+
return { ok: true, payload, ak: credential.ak };
|
|
61
72
|
} catch (err) {
|
|
62
73
|
continue;
|
|
63
74
|
}
|
|
@@ -145,7 +156,16 @@ function renderHome(scripts) {
|
|
|
145
156
|
<li>Trigger: <code>GET /api/<script-name></code> (for example <code>/api/check</code>)</li>
|
|
146
157
|
<li>Auto-loads scripts from local <code>package.json</code> (config entries take precedence on name conflicts)</li>
|
|
147
158
|
<li>Auth: send JWT via <code>Authorization: Bearer <token></code>, <code>x-runner-token</code>, or <code>?token=</code></li>
|
|
159
|
+
<li>AK/SK mode: generate JWT at <a href="https://jwt.io" target="_blank" rel="noopener noreferrer">jwt.io</a> using your script AK's SK</li>
|
|
148
160
|
</ul>
|
|
161
|
+
<h3>jwt.io payload guide</h3>
|
|
162
|
+
<p>Use HS256 and sign with the SK for your AK. Suggested payload:</p>
|
|
163
|
+
<pre>{
|
|
164
|
+
"sub": "gitai",
|
|
165
|
+
"ak": "<your-ak>",
|
|
166
|
+
"script": "<script-name>",
|
|
167
|
+
"exp": 1893456000
|
|
168
|
+
}</pre>
|
|
149
169
|
<label>script:</label>
|
|
150
170
|
<select id="name">${options}</select>
|
|
151
171
|
<label>jwt token:</label>
|
|
@@ -225,7 +245,7 @@ function createServer({ scripts, auditDir }) {
|
|
|
225
245
|
return;
|
|
226
246
|
}
|
|
227
247
|
|
|
228
|
-
const authResult =
|
|
248
|
+
const authResult = verifyJwtWithAkSk(token, scriptConfig.akSk || []);
|
|
229
249
|
if (!authResult.ok) {
|
|
230
250
|
send(res, 403, "Forbidden: invalid token");
|
|
231
251
|
return;
|
|
@@ -323,6 +343,40 @@ function createServer({ scripts, auditDir }) {
|
|
|
323
343
|
function startServer({ scripts, auditDir, port }) {
|
|
324
344
|
const server = createServer({ scripts, auditDir });
|
|
325
345
|
server.listen(port, () => {
|
|
346
|
+
const lines = [];
|
|
347
|
+
lines.push("=== Script Runner Effective Config ===");
|
|
348
|
+
lines.push(`Port: ${port}`);
|
|
349
|
+
lines.push(`Audit dir: ${auditDir}`);
|
|
350
|
+
lines.push(`Scripts: ${Object.keys(scripts).length}`);
|
|
351
|
+
lines.push("");
|
|
352
|
+
lines.push("Configured scripts:");
|
|
353
|
+
|
|
354
|
+
for (const [scriptName, script] of Object.entries(scripts)) {
|
|
355
|
+
const runTarget = script.scriptPath
|
|
356
|
+
? `bash ${script.scriptPath}`
|
|
357
|
+
: `${script.command || ""} ${Array.isArray(script.args) ? script.args.join(" ") : ""}`.trim();
|
|
358
|
+
lines.push(`- ${scriptName}`);
|
|
359
|
+
lines.push(` rootDir : ${script.rootDir}`);
|
|
360
|
+
lines.push(` run : ${runTarget}`);
|
|
361
|
+
|
|
362
|
+
if (Array.isArray(script.akSk) && script.akSk.length > 0) {
|
|
363
|
+
lines.push(" secrets :");
|
|
364
|
+
for (const item of script.akSk) {
|
|
365
|
+
lines.push(` - ${item.ak} => ${item.sk}`);
|
|
366
|
+
}
|
|
367
|
+
} else {
|
|
368
|
+
lines.push(" secrets : (none)");
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
lines.push("");
|
|
373
|
+
lines.push("JWT generation (recommended): https://jwt.io");
|
|
374
|
+
lines.push("Header : {\"alg\":\"HS256\",\"typ\":\"JWT\"}");
|
|
375
|
+
lines.push("Payload: {\"sub\":\"<user>\",\"ak\":\"<ak>\",\"script\":\"<script-name>\"}");
|
|
376
|
+
lines.push("Secret : use the SK mapped to that AK in your config");
|
|
377
|
+
lines.push("======================================");
|
|
378
|
+
|
|
379
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
326
380
|
process.stdout.write(`Server listening on http://0.0.0.0:${port}\n`);
|
|
327
381
|
});
|
|
328
382
|
return server;
|