@stage-labs/metro 0.1.0-beta.134 → 0.1.0-beta.136
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/dist/serve.js +11 -6
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +12 -6
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/boot/local-owner.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +35 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/owner.ts +49 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/server/control.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/server/update.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/terminal/api.ts +2 -1
- package/runtime/node_modules/@metro-labs/http/package.json +2 -1
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +20 -2
- package/runtime/node_modules/@metro-labs/http/src/workos-token.ts +150 -0
- package/runtime/runtime.json +1 -1
package/dist/serve.js
CHANGED
|
@@ -21,9 +21,12 @@ function portOf(raw) {
|
|
|
21
21
|
throw new Error(`'${raw ?? ''}' is not a port — ${USAGE}`);
|
|
22
22
|
return n;
|
|
23
23
|
}
|
|
24
|
+
const ORGANIZATION = /^org_[A-Za-z0-9]{10,64}$/;
|
|
24
25
|
function ownerOf(raw) {
|
|
26
|
+
if (raw !== undefined && ORGANIZATION.test(raw))
|
|
27
|
+
return raw;
|
|
25
28
|
if (raw === undefined || !ADDRESS.test(raw))
|
|
26
|
-
throw new Error(`'${raw ?? ''}' is
|
|
29
|
+
throw new Error(`'${raw ?? ''}' is neither an organization id (org_…) nor an Ethereum address — ${USAGE}`);
|
|
27
30
|
return raw.toLowerCase();
|
|
28
31
|
}
|
|
29
32
|
export function parseServeArgs(argv) {
|
|
@@ -60,15 +63,17 @@ export function parseServeArgs(argv) {
|
|
|
60
63
|
export function requireOwner(owner, dir = agentsDir()) {
|
|
61
64
|
if (owner !== null || existsSync(join(dir, '.owner')))
|
|
62
65
|
return;
|
|
63
|
-
throw new Error('no owner is set for this machine, so
|
|
64
|
-
'Pass the wallet that owns it once; it is remembered in ' +
|
|
66
|
+
throw new Error('no owner is set for this machine, so nobody could sign in.\n' +
|
|
67
|
+
'Pass the organization (or wallet) that owns it once; it is remembered in ' +
|
|
65
68
|
join(dir, '.owner') +
|
|
66
|
-
':\n metro serve --owner <address>');
|
|
69
|
+
':\n metro serve --owner <org_… or address>');
|
|
67
70
|
}
|
|
68
71
|
export function readOwner(dir = agentsDir()) {
|
|
69
72
|
try {
|
|
70
|
-
const raw = readFileSync(join(dir, '.owner'), 'utf8').trim()
|
|
71
|
-
|
|
73
|
+
const raw = readFileSync(join(dir, '.owner'), 'utf8').trim();
|
|
74
|
+
if (ORGANIZATION.test(raw))
|
|
75
|
+
return raw;
|
|
76
|
+
return ADDRESS.test(raw.toLowerCase()) ? raw.toLowerCase() : null;
|
|
72
77
|
}
|
|
73
78
|
catch {
|
|
74
79
|
return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.136",
|
|
4
4
|
"description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.136",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, the standing rules loaded at each session start, and a refresh skill for when the daemon could not keep the server list current. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -4,7 +4,7 @@ import { parseId } from '@metro-labs/core/ids';
|
|
|
4
4
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
5
5
|
import {
|
|
6
6
|
apiFailure,
|
|
7
|
-
apiSession,
|
|
7
|
+
apiSession, requireAdmin,
|
|
8
8
|
projectParam,
|
|
9
9
|
cors,
|
|
10
10
|
sendJson,
|
|
@@ -190,6 +190,7 @@ async function routeAgent(
|
|
|
190
190
|
tgt: AgentTarget,
|
|
191
191
|
): Promise<void> {
|
|
192
192
|
try {
|
|
193
|
+
if (tgt.kind !== 'collection') requireAdmin(session);
|
|
193
194
|
if (tgt.kind === 'key') await handleResetKey(req, res, deps, session, tgt.id);
|
|
194
195
|
else if (tgt.kind === 'agent')
|
|
195
196
|
await handleDelete(req, res, deps, session, tgt.id);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, rmdirSync, rmSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
4
|
+
import { isOrganizationId } from '@metro-labs/http/workos-token';
|
|
4
5
|
import { ensureSecureDir, writeSecure } from '@metro-labs/core/secure-fs';
|
|
5
6
|
import {
|
|
6
7
|
AgentAdminError,
|
|
@@ -38,26 +39,31 @@ interface Stored {
|
|
|
38
39
|
|
|
39
40
|
const missing = (): AgentAdminError => new AgentAdminError('no such agent', 404);
|
|
40
41
|
|
|
42
|
+
export function parseOwner(raw: string): string | null {
|
|
43
|
+
const text = raw.trim();
|
|
44
|
+
return isOrganizationId(text) ? text : normalizeAddress(text);
|
|
45
|
+
}
|
|
46
|
+
|
|
41
47
|
export function localOwner(dir = agentsDir()): string | null {
|
|
42
48
|
try {
|
|
43
|
-
return
|
|
49
|
+
return parseOwner(readFileSync(join(dir, OWNER_FILE), 'utf8'));
|
|
44
50
|
} catch {
|
|
45
51
|
return null;
|
|
46
52
|
}
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
export function setLocalOwner(raw: string, dir = agentsDir()): string {
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
56
|
+
const owner = parseOwner(raw);
|
|
57
|
+
if (owner === null) throw new ApiError(`'${raw}' is neither an organization id nor an Ethereum address`, 400);
|
|
52
58
|
ensureSecureDir(dir);
|
|
53
|
-
writeSecure(join(dir, OWNER_FILE), `${
|
|
54
|
-
return
|
|
59
|
+
writeSecure(join(dir, OWNER_FILE), `${owner}\n`);
|
|
60
|
+
return owner;
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
|
|
58
64
|
function isOwner(subject: string, dir: string): boolean {
|
|
59
65
|
const owner = localOwner(dir);
|
|
60
|
-
return owner !== null && owner ===
|
|
66
|
+
return owner !== null && owner === parseOwner(subject);
|
|
61
67
|
}
|
|
62
68
|
|
|
63
69
|
export function storedAgents(dir: string): Stored[] {
|
|
@@ -26,6 +26,7 @@ import { ensureMetroPlugin } from '../claude/plugin-install.js';
|
|
|
26
26
|
import { sessionRunning, stopSession, unwatchSession, watchSession } from '../claude/session.js';
|
|
27
27
|
import { tryClaudeSetup } from '../claude/setup.js';
|
|
28
28
|
import { applyLocalOwner } from './local-owner.js';
|
|
29
|
+
import { installBearerSessions } from '../routes/bearer.js';
|
|
29
30
|
import { ensureLocalAgent, localOwner } from '../agents/file-admin.js';
|
|
30
31
|
import { migrateAgentLayout } from '../agents/files.js';
|
|
31
32
|
import { ensureStationDeps } from '../stations/runtime-deps.js';
|
|
@@ -140,6 +141,7 @@ function startConnectors(): void {
|
|
|
140
141
|
|
|
141
142
|
async function main(): Promise<void> {
|
|
142
143
|
applyLocalOwner();
|
|
144
|
+
installBearerSessions(agentsDir(), localOwner);
|
|
143
145
|
migrateAgentLayout();
|
|
144
146
|
log.info({ agent: await ensureLocalAgent() }, 'local daemon: agent');
|
|
145
147
|
await materializeFrom(fileSource, { allowEmpty: true });
|
|
@@ -9,6 +9,6 @@ export function applyLocalOwner(): string | null {
|
|
|
9
9
|
return address;
|
|
10
10
|
}
|
|
11
11
|
const stored = localOwner();
|
|
12
|
-
if (stored === null) log.warn('local daemon: no owner set, so
|
|
12
|
+
if (stored === null) log.warn('local daemon: no owner set, so nobody can sign in; restart with metro serve --owner <organization id or address>');
|
|
13
13
|
return stored;
|
|
14
14
|
}
|
|
@@ -311,6 +311,10 @@ export class InboundRelay {
|
|
|
311
311
|
ev: Record<string, unknown>,
|
|
312
312
|
base: EventBase,
|
|
313
313
|
): Promise<void> {
|
|
314
|
+
if (base.evType === 'msg' && base.text.trim() === '') {
|
|
315
|
+
this.deps.log('drop: empty message', base.station, base.line, str(ev.messageId));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
314
318
|
if (base.evType === 'msg' && (await this.handlePermissionReply(base.text)))
|
|
315
319
|
return;
|
|
316
320
|
await this.notify('notifications/claude/channel', {
|
|
@@ -357,7 +361,6 @@ export class InboundRelay {
|
|
|
357
361
|
await this.handleReact(ev, base);
|
|
358
362
|
return;
|
|
359
363
|
}
|
|
360
|
-
|
|
361
364
|
await this.emitMessage(ev, base);
|
|
362
365
|
}
|
|
363
366
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
-
import { apiFailure, apiSession, cors, readJsonBody, sendJson } from '@metro-labs/http/api-http';
|
|
3
|
+
import { apiFailure, apiSession, requireAdmin, cors, readJsonBody, sendJson } from '@metro-labs/http/api-http';
|
|
4
4
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
5
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
6
6
|
import { listClaudeSettings, SETTINGS_MAX, writeClaudeSettings } from './settings.js';
|
|
@@ -147,6 +147,7 @@ async function created(req: IncomingMessage, path: string, dir: string): Promise
|
|
|
147
147
|
return createClaudeSkill(body.name, scope, typeof body.text === 'string' ? body.text : undefined, dir);
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
const ADMIN_ONLY = /^\/(login|session|version|setup)(\/|$)/;
|
|
150
151
|
const LOGIN = 'login';
|
|
151
152
|
const SESSION = 'session';
|
|
152
153
|
const SETUP = 'setup';
|
|
@@ -343,6 +344,7 @@ export function handleClaudeRequest(
|
|
|
343
344
|
.then(async (session) => {
|
|
344
345
|
if (!session) throw new ApiError('unauthorized', 401);
|
|
345
346
|
deps.authorize(session.subject);
|
|
347
|
+
if (req.method !== 'GET' && ADMIN_ONLY.test(path)) requireAdmin(session);
|
|
346
348
|
if (await streamed(req, res, path, search, (deps.dir ?? claudeDir)())) return;
|
|
347
349
|
sendJson(req, res, 200, await routed(req, path, search, deps));
|
|
348
350
|
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
4
|
+
import { setBearerSessions, type ApiSession } from '@metro-labs/http/api-http';
|
|
5
|
+
import { bearerSession, clientId, jwksUrl, SigningKeys, workosBase, type KeyStore } from '@metro-labs/http/workos-token';
|
|
6
|
+
import { ensureSecureDir, writeSecure } from '@metro-labs/core/secure-fs';
|
|
7
|
+
|
|
8
|
+
const KEYS_FILE = '.jwks';
|
|
9
|
+
|
|
10
|
+
export function jwksStore(dir: string): KeyStore {
|
|
11
|
+
const path = join(dir, KEYS_FILE);
|
|
12
|
+
return {
|
|
13
|
+
read: () => (existsSync(path) ? readFileSync(path, 'utf8') : null),
|
|
14
|
+
write: (text) => {
|
|
15
|
+
ensureSecureDir(dir);
|
|
16
|
+
writeSecure(path, text);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function bearerSessionsFor(owner: () => string | null, keys: SigningKeys): (req: Parameters<typeof bearerSession>[0]) => Promise<ApiSession | null> {
|
|
22
|
+
return async (req) => {
|
|
23
|
+
const session = await bearerSession(req, keys);
|
|
24
|
+
if (session === null) return null;
|
|
25
|
+
const held = owner();
|
|
26
|
+
if (held === null || session.organization === null || session.organization !== held)
|
|
27
|
+
throw new ApiError('this machine belongs to another organization', 403);
|
|
28
|
+
return { subject: held, role: session.role === 'admin' ? 'admin' : 'member' };
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function installBearerSessions(dir: string, owner: () => string | null, env: NodeJS.ProcessEnv = process.env): void {
|
|
33
|
+
const keys = new SigningKeys(jwksUrl(clientId(env), workosBase(env)), jwksStore(dir));
|
|
34
|
+
setBearerSessions(bearerSessionsFor(owner, keys));
|
|
35
|
+
}
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
import { attachmentEventUrl, handleAttachRequest } from '../files/attach-serve.js';
|
|
34
34
|
import { webhookEntry } from '@metro-labs/webhook';
|
|
35
35
|
import { handleIdentityRequest } from './identity.js';
|
|
36
|
+
import { handleOwnerRequest } from './owner.js';
|
|
36
37
|
import { attachTerminalSockets } from '../terminal/socket.js';
|
|
37
38
|
import { tmuxCommand } from '../terminal/api.js';
|
|
38
39
|
import { handleModeRequest } from '@metro-labs/http/mode-api';
|
|
@@ -327,6 +328,7 @@ function handleSignInRoutes(
|
|
|
327
328
|
apis: SessionApis,
|
|
328
329
|
): boolean {
|
|
329
330
|
if (apis.identity !== undefined && handleIdentityRequest(req, res, apis.identity)) return true;
|
|
331
|
+
if (apis.owner !== undefined && handleOwnerRequest(req, res, apis.owner)) return true;
|
|
330
332
|
return apis.mode !== undefined && handleModeRequest(req, res, apis.mode);
|
|
331
333
|
}
|
|
332
334
|
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
localOwner,
|
|
44
44
|
localResetAgentKey,
|
|
45
45
|
readLocalAgentFile,
|
|
46
|
+
setLocalOwner,
|
|
46
47
|
} from '../agents/file-admin.js';
|
|
47
48
|
import { listAgentFiles, readAgentFile } from '../agents/files.js';
|
|
48
49
|
import { readModelConfig } from '../gateway/model-config.js';
|
|
@@ -218,6 +219,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
218
219
|
gateway: { config: readModelConfig },
|
|
219
220
|
terminalApi: { authorize: (subject) => { assertLocalOwner(subject); } },
|
|
220
221
|
identity: { owner: localOwner },
|
|
222
|
+
owner: { owner: localOwner, setOwner: setLocalOwner },
|
|
221
223
|
mode: localModeInfo,
|
|
222
224
|
};
|
|
223
225
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { log } from '@metro-labs/core/log';
|
|
3
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
4
|
+
import { apiFailure, apiSession, bodyField, cors, readJsonBody, sendJson } from '@metro-labs/http/api-http';
|
|
5
|
+
import { resetIdentities } from '@metro-labs/http/identity-registry';
|
|
6
|
+
import { isOrganizationId } from '@metro-labs/http/workos-token';
|
|
7
|
+
|
|
8
|
+
const PATH = '/api/owner';
|
|
9
|
+
|
|
10
|
+
export interface OwnerRouteDeps {
|
|
11
|
+
owner: () => string | null;
|
|
12
|
+
setOwner: (owner: string) => string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function claim(req: IncomingMessage, deps: OwnerRouteDeps): Promise<{ owner: string; previous: string }> {
|
|
16
|
+
const session = await apiSession(req);
|
|
17
|
+
if (session === null) throw new ApiError('unauthorized', 401);
|
|
18
|
+
const previous = deps.owner();
|
|
19
|
+
if (previous === null) throw new ApiError('this machine has no owner yet', 403);
|
|
20
|
+
if (isOrganizationId(previous)) throw new ApiError('this machine already belongs to an organization', 409);
|
|
21
|
+
if (session.subject !== previous) throw new ApiError('only the wallet that owns this machine can hand it to an organization', 403);
|
|
22
|
+
const wanted = bodyField(await readJsonBody(req), 'owner');
|
|
23
|
+
if (typeof wanted !== 'string' || !isOrganizationId(wanted.trim())) throw new ApiError('owner must be an organization id (org_…)', 400);
|
|
24
|
+
const owner = deps.setOwner(wanted.trim());
|
|
25
|
+
resetIdentities();
|
|
26
|
+
log.info({ previous, owner }, 'owner: this machine now belongs to an organization; the wallet is no longer accepted');
|
|
27
|
+
return { owner, previous };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function handleOwnerRequest(req: IncomingMessage, res: ServerResponse, deps: OwnerRouteDeps): boolean {
|
|
31
|
+
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
32
|
+
if (path !== PATH) return false;
|
|
33
|
+
if (req.method === 'OPTIONS') {
|
|
34
|
+
res.writeHead(204, cors(req)).end();
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
if (req.method !== 'POST') {
|
|
38
|
+
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
claim(req, deps)
|
|
42
|
+
.then((body) => {
|
|
43
|
+
sendJson(req, res, 200, body);
|
|
44
|
+
})
|
|
45
|
+
.catch((err: unknown) => {
|
|
46
|
+
apiFailure(req, res, err, 'owner');
|
|
47
|
+
});
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from '../connectors/api.js';
|
|
8
8
|
import type { RelayApiDeps } from '../connectors/relay.js';
|
|
9
9
|
import type { IdentityRouteDeps } from './identity.js';
|
|
10
|
+
import type { OwnerRouteDeps } from './owner.js';
|
|
10
11
|
import { handleLocalCliRequest, type LocalCliDeps } from '../connectors/cli-api.js';
|
|
11
12
|
import { handleClaudeRequest, type ClaudeApiDeps } from '../claude/api.js';
|
|
12
13
|
import type { ModeInfo } from '@metro-labs/http/mode-api';
|
|
@@ -32,6 +33,7 @@ export interface SessionApis {
|
|
|
32
33
|
connectorApi?: ConnectorApiDeps;
|
|
33
34
|
relayApi?: RelayApiDeps;
|
|
34
35
|
identity?: IdentityRouteDeps;
|
|
36
|
+
owner?: OwnerRouteDeps;
|
|
35
37
|
mode?: () => ModeInfo;
|
|
36
38
|
}
|
|
37
39
|
|
|
@@ -20,7 +20,7 @@ export function handleSessionApiRequest(
|
|
|
20
20
|
apiSession(req)
|
|
21
21
|
.then((session) => {
|
|
22
22
|
if (!session) sendJson(req, res, 401, { error: 'unauthorized' });
|
|
23
|
-
else sendJson(req, res, 200, { subject: session.subject });
|
|
23
|
+
else sendJson(req, res, 200, { subject: session.subject, role: session.role });
|
|
24
24
|
})
|
|
25
25
|
.catch((err: unknown) => {
|
|
26
26
|
apiFailure(req, res, err, 'session');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
3
|
-
import { apiFailure, apiSession, cors, sendJson } from '@metro-labs/http/api-http';
|
|
3
|
+
import { apiFailure, apiSession, requireAdmin, cors, sendJson } from '@metro-labs/http/api-http';
|
|
4
4
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
5
5
|
|
|
6
6
|
const STOP = '/api/stop';
|
|
@@ -44,6 +44,7 @@ export function handleControlRequest(req: IncomingMessage, res: ServerResponse,
|
|
|
44
44
|
const session = await apiSession(req);
|
|
45
45
|
if (!session) throw new ApiError('unauthorized', 401);
|
|
46
46
|
deps.authorize(session.subject);
|
|
47
|
+
requireAdmin(session);
|
|
47
48
|
if (!(deps.served ?? servedByCli)())
|
|
48
49
|
throw new ApiError(
|
|
49
50
|
'this daemon was not started by metro serve, so nothing on the machine would bring it back: use the shell instead',
|
|
@@ -2,7 +2,7 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
3
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
4
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
|
-
import { apiFailure, apiSession, cors, sendJson } from '@metro-labs/http/api-http';
|
|
5
|
+
import { apiFailure, apiSession, requireAdmin, cors, sendJson } from '@metro-labs/http/api-http';
|
|
6
6
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
7
7
|
import { METRO_VERSION } from '@metro-labs/core/version';
|
|
8
8
|
|
|
@@ -98,6 +98,7 @@ export function handleUpdateRequest(req: IncomingMessage, res: ServerResponse, d
|
|
|
98
98
|
const session = await apiSession(req);
|
|
99
99
|
if (!session) throw new ApiError('unauthorized', 401);
|
|
100
100
|
deps.authorize(session.subject);
|
|
101
|
+
if (req.method === 'POST') requireAdmin(session);
|
|
101
102
|
const bin = binOrThrow(deps);
|
|
102
103
|
if (req.method === 'GET') return { ...(await check(bin)), running: METRO_VERSION };
|
|
103
104
|
return apply(bin, deps);
|
|
@@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
4
4
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
|
-
import { apiFailure, apiSession, bodyField, cors, readJsonBody, sendJson } from '@metro-labs/http/api-http';
|
|
5
|
+
import { apiFailure, apiSession, requireAdmin, bodyField, cors, readJsonBody, sendJson } from '@metro-labs/http/api-http';
|
|
6
6
|
import { log } from '@metro-labs/core/log';
|
|
7
7
|
import { mintTerminalTicket } from './tickets.js';
|
|
8
8
|
|
|
@@ -80,6 +80,7 @@ export function handleTerminalRequest(req: IncomingMessage, res: ServerResponse,
|
|
|
80
80
|
.then(async (session) => {
|
|
81
81
|
if (!session) throw new ApiError('unauthorized', 401);
|
|
82
82
|
deps.authorize(session.subject);
|
|
83
|
+
requireAdmin(session);
|
|
83
84
|
if (path === PREFIX) {
|
|
84
85
|
sendJson(req, res, 200, { available: tmuxAvailable(deps), sessions: tmuxSessions() });
|
|
85
86
|
return;
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"./identity-key": "./src/identity-key.ts",
|
|
13
13
|
"./identity-registry": "./src/identity-registry.ts",
|
|
14
14
|
"./return-to": "./src/return-to.ts",
|
|
15
|
-
"./mode-api": "./src/mode-api.ts"
|
|
15
|
+
"./mode-api": "./src/mode-api.ts",
|
|
16
|
+
"./workos-token": "./src/workos-token.ts"
|
|
16
17
|
},
|
|
17
18
|
"scripts": {
|
|
18
19
|
"build": "tsc",
|
|
@@ -6,8 +6,23 @@ import { identitySubject } from './identity-registry.js';
|
|
|
6
6
|
|
|
7
7
|
const BODY_MAX = 4 * 1024;
|
|
8
8
|
|
|
9
|
+
export type Role = 'admin' | 'member';
|
|
10
|
+
|
|
9
11
|
export interface ApiSession {
|
|
10
12
|
subject: string;
|
|
13
|
+
role: Role;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type BearerSessions = (req: IncomingMessage) => Promise<ApiSession | null>;
|
|
17
|
+
|
|
18
|
+
let bearerSessions: BearerSessions | null = null;
|
|
19
|
+
|
|
20
|
+
export function setBearerSessions(fn: BearerSessions | null): void {
|
|
21
|
+
bearerSessions = fn;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function requireAdmin(session: ApiSession): void {
|
|
25
|
+
if (session.role !== 'admin') throw new ApiError('this needs the admin role in your organization', 403);
|
|
11
26
|
}
|
|
12
27
|
|
|
13
28
|
export function cors(req: IncomingMessage): Record<string, string> {
|
|
@@ -37,8 +52,11 @@ export function sendJson(
|
|
|
37
52
|
|
|
38
53
|
export async function apiSession(req: IncomingMessage): Promise<ApiSession | null> {
|
|
39
54
|
const address = await signedIdentity(req);
|
|
40
|
-
|
|
41
|
-
|
|
55
|
+
if (address !== null) {
|
|
56
|
+
const subject = identitySubject(address);
|
|
57
|
+
return subject === undefined ? null : { subject, role: 'admin' };
|
|
58
|
+
}
|
|
59
|
+
return bearerSessions === null ? null : bearerSessions(req);
|
|
42
60
|
}
|
|
43
61
|
|
|
44
62
|
export interface AgentIdentity {
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
import { createPublicKey, verify, type JsonWebKey, type KeyObject } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
export const WORKOS_API = 'https://api.workos.com';
|
|
5
|
+
const ORG_RE = /^org_[A-Za-z0-9]{10,64}$/;
|
|
6
|
+
export const isOrganizationId = (value: string): boolean => ORG_RE.test(value);
|
|
7
|
+
export const WORKOS_ISSUER = 'https://api.workos.com';
|
|
8
|
+
export const DEFAULT_CLIENT_ID = 'client_01M2TJJJ6RM9CT081QB4XZK3G2';
|
|
9
|
+
const SKEW_S = 60;
|
|
10
|
+
const REFETCH_COOLDOWN_MS = 60_000;
|
|
11
|
+
const FETCH_MS = 10_000;
|
|
12
|
+
|
|
13
|
+
export interface Session {
|
|
14
|
+
userId: string;
|
|
15
|
+
sessionId: string;
|
|
16
|
+
organization: string | null;
|
|
17
|
+
role: string | null;
|
|
18
|
+
expiresAt: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface KeyStore {
|
|
22
|
+
read: () => string | null;
|
|
23
|
+
write: (text: string) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const filled = (value: string | undefined): string | null => {
|
|
27
|
+
const text = value?.trim() ?? '';
|
|
28
|
+
return text === '' ? null : text;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const clientId = (env: NodeJS.ProcessEnv = process.env): string => filled(env.WORKOS_CLIENT_ID) ?? DEFAULT_CLIENT_ID;
|
|
32
|
+
export const workosBase = (env: NodeJS.ProcessEnv = process.env): string => filled(env.WORKOS_API_BASE) ?? WORKOS_API;
|
|
33
|
+
export const jwksUrl = (id: string, base = WORKOS_API): string => `${base}/sso/jwks/${id}`;
|
|
34
|
+
|
|
35
|
+
function jwksKeys(text: string): Map<string, KeyObject> {
|
|
36
|
+
const keys = new Map<string, KeyObject>();
|
|
37
|
+
const parsed: unknown = JSON.parse(text);
|
|
38
|
+
const list = typeof parsed === 'object' && parsed !== null ? (parsed as { keys?: unknown }).keys : undefined;
|
|
39
|
+
if (!Array.isArray(list)) return keys;
|
|
40
|
+
for (const jwk of list) {
|
|
41
|
+
if (typeof jwk !== 'object' || jwk === null) continue;
|
|
42
|
+
const { kid, kty } = jwk as { kid?: unknown; kty?: unknown };
|
|
43
|
+
if (typeof kid !== 'string' || kty !== 'RSA') continue;
|
|
44
|
+
try {
|
|
45
|
+
keys.set(kid, createPublicKey({ key: jwk as JsonWebKey, format: 'jwk' }));
|
|
46
|
+
} catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return keys;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class SigningKeys {
|
|
54
|
+
private keys = new Map<string, KeyObject>();
|
|
55
|
+
private lastMiss = 0;
|
|
56
|
+
private loaded = false;
|
|
57
|
+
|
|
58
|
+
constructor(
|
|
59
|
+
private readonly url: string,
|
|
60
|
+
private readonly store?: KeyStore,
|
|
61
|
+
) {}
|
|
62
|
+
|
|
63
|
+
private load(): void {
|
|
64
|
+
if (this.loaded) return;
|
|
65
|
+
this.loaded = true;
|
|
66
|
+
const cached = this.store?.read() ?? null;
|
|
67
|
+
if (cached !== null) this.keys = jwksKeys(cached);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private async refresh(now: number): Promise<void> {
|
|
71
|
+
if (now - this.lastMiss < REFETCH_COOLDOWN_MS) return;
|
|
72
|
+
const res = await fetch(this.url, { signal: AbortSignal.timeout(FETCH_MS) });
|
|
73
|
+
if (!res.ok) throw new Error(`the signing keys answered ${String(res.status)}`);
|
|
74
|
+
const text = await res.text();
|
|
75
|
+
const keys = jwksKeys(text);
|
|
76
|
+
if (keys.size === 0) throw new Error('the signing keys answered no RSA key');
|
|
77
|
+
this.keys = keys;
|
|
78
|
+
this.store?.write(text);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async keyFor(kid: string, now = Date.now()): Promise<KeyObject | null> {
|
|
82
|
+
this.load();
|
|
83
|
+
const held = this.keys.get(kid);
|
|
84
|
+
if (held !== undefined) return held;
|
|
85
|
+
await this.refresh(now);
|
|
86
|
+
const fetched = this.keys.get(kid);
|
|
87
|
+
if (fetched === undefined) this.lastMiss = now;
|
|
88
|
+
return fetched ?? null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const b64url = (text: string): Buffer => Buffer.from(text.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
|
93
|
+
|
|
94
|
+
function jsonPart(text: string): Record<string, unknown> | null {
|
|
95
|
+
try {
|
|
96
|
+
const parsed: unknown = JSON.parse(b64url(text).toString('utf8'));
|
|
97
|
+
return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const str = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
|
|
104
|
+
|
|
105
|
+
const issuedBy = (iss: unknown, issuer: string): boolean => typeof iss === 'string' && (iss === issuer || iss.startsWith(`${issuer}/`));
|
|
106
|
+
|
|
107
|
+
function sessionOf(claims: Record<string, unknown>, issuer: string, now: number): Session | null {
|
|
108
|
+
const exp = typeof claims.exp === 'number' ? claims.exp : null;
|
|
109
|
+
const userId = str(claims.sub);
|
|
110
|
+
const sessionId = str(claims.sid);
|
|
111
|
+
if (!issuedBy(claims.iss, issuer) || exp === null || userId === null || sessionId === null) return null;
|
|
112
|
+
if (exp * 1000 < now - SKEW_S * 1000) return null;
|
|
113
|
+
return { userId, sessionId, organization: str(claims.org_id), role: str(claims.role), expiresAt: exp * 1000 };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface Parts {
|
|
117
|
+
signed: string;
|
|
118
|
+
body: string;
|
|
119
|
+
signature: Buffer;
|
|
120
|
+
kid: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseToken(token: string): Parts | null {
|
|
124
|
+
const [head, body, sig] = token.split('.');
|
|
125
|
+
if (head === undefined || body === undefined || sig === undefined) return null;
|
|
126
|
+
const header = jsonPart(head);
|
|
127
|
+
const kid = str(header?.kid);
|
|
128
|
+
if (header?.alg !== 'RS256' || kid === null) return null;
|
|
129
|
+
return { signed: `${head}.${body}`, body, signature: b64url(sig), kid };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function verifyToken(token: string, keys: SigningKeys, issuer = WORKOS_ISSUER, now = Date.now()): Promise<Session | null> {
|
|
133
|
+
const parts = parseToken(token);
|
|
134
|
+
if (parts === null) return null;
|
|
135
|
+
const key = await keys.keyFor(parts.kid, now).catch(() => null);
|
|
136
|
+
if (key === null || !verify('sha256', Buffer.from(parts.signed), key, parts.signature)) return null;
|
|
137
|
+
const claims = jsonPart(parts.body);
|
|
138
|
+
return claims === null ? null : sessionOf(claims, issuer, now);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function bearerToken(req: IncomingMessage): string | null {
|
|
142
|
+
const header = req.headers.authorization ?? '';
|
|
143
|
+
const [scheme, token] = header.trim().split(/\s+/);
|
|
144
|
+
return scheme?.toLowerCase() === 'bearer' && token?.includes('.') === true ? token : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function bearerSession(req: IncomingMessage, keys: SigningKeys, issuer = WORKOS_ISSUER): Promise<Session | null> {
|
|
148
|
+
const token = bearerToken(req);
|
|
149
|
+
return token === null ? null : verifyToken(token, keys, issuer);
|
|
150
|
+
}
|
package/runtime/runtime.json
CHANGED