@stage-labs/metro 0.1.0-beta.137 → 0.1.0-beta.138
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 +2 -2
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/package.json +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/files.ts +4 -3
- package/runtime/node_modules/@metro-labs/daemon/src/boot/local-owner.ts +23 -7
- package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +0 -4
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +0 -3
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -4
- package/runtime/node_modules/@metro-labs/http/package.json +1 -6
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +3 -10
- package/runtime/runtime.json +1 -1
- package/runtime/stations.json +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/routes/identity.ts +0 -54
- package/runtime/node_modules/@metro-labs/daemon/src/routes/owner.ts +0 -49
- package/runtime/node_modules/@metro-labs/http/src/identity-key.ts +0 -33
- package/runtime/node_modules/@metro-labs/http/src/identity-registry.ts +0 -13
- package/runtime/node_modules/@metro-labs/http/src/signed-identity.ts +0 -36
package/dist/serve.js
CHANGED
|
@@ -64,9 +64,9 @@ export function requireOwner(owner, dir = agentsDir()) {
|
|
|
64
64
|
if (owner !== null || existsSync(join(dir, '.owner')))
|
|
65
65
|
return;
|
|
66
66
|
throw new Error('no owner is set for this machine, so nobody could sign in.\n' +
|
|
67
|
-
'Pass the organization
|
|
67
|
+
'Pass the organization that owns it once; it is remembered in ' +
|
|
68
68
|
join(dir, '.owner') +
|
|
69
|
-
':\n metro serve --owner <org_
|
|
69
|
+
':\n metro serve --owner <org_…>');
|
|
70
70
|
}
|
|
71
71
|
export function readOwner(dir = agentsDir()) {
|
|
72
72
|
try {
|
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.138",
|
|
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.138",
|
|
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",
|
|
@@ -10,7 +10,6 @@ import { isRecord } from '@metro-labs/core/is-record';
|
|
|
10
10
|
export const AGENT_FILE = 'agent.json';
|
|
11
11
|
|
|
12
12
|
const KEY_RE = /^[A-Za-z0-9_-]{16,128}$/;
|
|
13
|
-
const ADDRESS_RE = /^0x[0-9a-f]{40}$/;
|
|
14
13
|
const STATION_NAMES = new Set<string>(STATIONS);
|
|
15
14
|
|
|
16
15
|
export class AgentFileError extends Error {}
|
|
@@ -34,6 +33,8 @@ export function agentsDir(): string {
|
|
|
34
33
|
: join(homedir(), '.metro', 'agents');
|
|
35
34
|
}
|
|
36
35
|
|
|
36
|
+
const OWNER_RE = /^(0x[0-9a-f]{40}|org_[A-Za-z0-9]{10,64})$/;
|
|
37
|
+
|
|
37
38
|
function fail(path: string, reason: string): never {
|
|
38
39
|
throw new AgentFileError(`${path}: ${reason}`);
|
|
39
40
|
}
|
|
@@ -98,9 +99,9 @@ export function parseAgentFile(raw: string, path: string): AgentFile {
|
|
|
98
99
|
const key = optionalMatch(parsed.key, KEY_RE, path, 'key is not an agent key');
|
|
99
100
|
const owner = optionalMatch(
|
|
100
101
|
parsed.owner,
|
|
101
|
-
|
|
102
|
+
OWNER_RE,
|
|
102
103
|
path,
|
|
103
|
-
'owner is
|
|
104
|
+
'owner is neither an organization id nor a lowercase Ethereum address',
|
|
104
105
|
);
|
|
105
106
|
if (!Array.isArray(stations)) fail(path, 'stations is not a list');
|
|
106
107
|
return {
|
|
@@ -1,14 +1,30 @@
|
|
|
1
1
|
import { localOwner, setLocalOwner } from '../agents/file-admin.js';
|
|
2
|
+
import { isOrganizationId } from '@metro-labs/http/workos-token';
|
|
2
3
|
import { log } from '@metro-labs/core/log';
|
|
3
4
|
|
|
5
|
+
const NO_ONE = 'nobody can sign in; restart with metro serve --owner <organization id>';
|
|
6
|
+
|
|
7
|
+
const WALLET = `the owner is a wallet address, and wallets cannot sign in any more; ${NO_ONE}`;
|
|
8
|
+
|
|
9
|
+
function keepsOrganization(wanted: string, stored: string | null): stored is string {
|
|
10
|
+
return wanted !== '' && stored !== null && isOrganizationId(stored) && !isOrganizationId(wanted);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function announce(owner: string | null): string | null {
|
|
14
|
+
if (owner === null) log.warn(`local daemon: no owner set, so ${NO_ONE}`);
|
|
15
|
+
else if (!isOrganizationId(owner)) log.warn(`local daemon: ${WALLET}`);
|
|
16
|
+
return owner;
|
|
17
|
+
}
|
|
18
|
+
|
|
4
19
|
export function applyLocalOwner(): string | null {
|
|
5
20
|
const wanted = process.env.METRO_OWNER?.trim() ?? '';
|
|
6
|
-
if (wanted !== '') {
|
|
7
|
-
const address = setLocalOwner(wanted);
|
|
8
|
-
log.info({ owner: address }, 'local daemon: owner set from METRO_OWNER');
|
|
9
|
-
return address;
|
|
10
|
-
}
|
|
11
21
|
const stored = localOwner();
|
|
12
|
-
if (
|
|
13
|
-
|
|
22
|
+
if (keepsOrganization(wanted, stored)) {
|
|
23
|
+
log.info({ owner: stored, ignored: wanted }, 'local daemon: this machine belongs to an organization, so the wallet in --owner is ignored');
|
|
24
|
+
return stored;
|
|
25
|
+
}
|
|
26
|
+
if (wanted === '') return announce(stored);
|
|
27
|
+
const owner = setLocalOwner(wanted);
|
|
28
|
+
log.info({ owner }, 'local daemon: owner set from METRO_OWNER');
|
|
29
|
+
return announce(owner);
|
|
14
30
|
}
|
|
@@ -32,8 +32,6 @@ import {
|
|
|
32
32
|
} from '../net/tunnel.js';
|
|
33
33
|
import { attachmentEventUrl, handleAttachRequest } from '../files/attach-serve.js';
|
|
34
34
|
import { webhookEntry } from '@metro-labs/webhook';
|
|
35
|
-
import { handleIdentityRequest } from './identity.js';
|
|
36
|
-
import { handleOwnerRequest } from './owner.js';
|
|
37
35
|
import { attachTerminalSockets } from '../terminal/socket.js';
|
|
38
36
|
import { tmuxCommand } from '../terminal/api.js';
|
|
39
37
|
import { handleModeRequest } from '@metro-labs/http/mode-api';
|
|
@@ -327,8 +325,6 @@ function handleSignInRoutes(
|
|
|
327
325
|
res: ServerResponse,
|
|
328
326
|
apis: SessionApis,
|
|
329
327
|
): boolean {
|
|
330
|
-
if (apis.identity !== undefined && handleIdentityRequest(req, res, apis.identity)) return true;
|
|
331
|
-
if (apis.owner !== undefined && handleOwnerRequest(req, res, apis.owner)) return true;
|
|
332
328
|
return apis.mode !== undefined && handleModeRequest(req, res, apis.mode);
|
|
333
329
|
}
|
|
334
330
|
|
|
@@ -43,7 +43,6 @@ import {
|
|
|
43
43
|
localOwner,
|
|
44
44
|
localResetAgentKey,
|
|
45
45
|
readLocalAgentFile,
|
|
46
|
-
setLocalOwner,
|
|
47
46
|
} from '../agents/file-admin.js';
|
|
48
47
|
import { listAgentFiles, readAgentFile } from '../agents/files.js';
|
|
49
48
|
import { readModelConfig } from '../gateway/model-config.js';
|
|
@@ -218,8 +217,6 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
218
217
|
modelApi: { authorize: (subject) => { assertLocalOwner(subject); } },
|
|
219
218
|
gateway: { config: readModelConfig },
|
|
220
219
|
terminalApi: { authorize: (subject) => { assertLocalOwner(subject); } },
|
|
221
|
-
identity: { owner: localOwner },
|
|
222
|
-
owner: { owner: localOwner, setOwner: setLocalOwner },
|
|
223
220
|
mode: localModeInfo,
|
|
224
221
|
};
|
|
225
222
|
}
|
|
@@ -6,8 +6,6 @@ import {
|
|
|
6
6
|
type ConnectorApiDeps,
|
|
7
7
|
} from '../connectors/api.js';
|
|
8
8
|
import type { RelayApiDeps } from '../connectors/relay.js';
|
|
9
|
-
import type { IdentityRouteDeps } from './identity.js';
|
|
10
|
-
import type { OwnerRouteDeps } from './owner.js';
|
|
11
9
|
import { handleLocalCliRequest, type LocalCliDeps } from '../connectors/cli-api.js';
|
|
12
10
|
import { handleClaudeRequest, type ClaudeApiDeps } from '../claude/api.js';
|
|
13
11
|
import type { ModeInfo } from '@metro-labs/http/mode-api';
|
|
@@ -32,8 +30,6 @@ export interface SessionApis {
|
|
|
32
30
|
claudeApi?: ClaudeApiDeps;
|
|
33
31
|
connectorApi?: ConnectorApiDeps;
|
|
34
32
|
relayApi?: RelayApiDeps;
|
|
35
|
-
identity?: IdentityRouteDeps;
|
|
36
|
-
owner?: OwnerRouteDeps;
|
|
37
33
|
mode?: () => ModeInfo;
|
|
38
34
|
}
|
|
39
35
|
|
|
@@ -8,9 +8,6 @@
|
|
|
8
8
|
"./cors": "./src/cors.ts",
|
|
9
9
|
"./api-http": "./src/api-http.ts",
|
|
10
10
|
"./api-error": "./src/api-error.ts",
|
|
11
|
-
"./signed-identity": "./src/signed-identity.ts",
|
|
12
|
-
"./identity-key": "./src/identity-key.ts",
|
|
13
|
-
"./identity-registry": "./src/identity-registry.ts",
|
|
14
11
|
"./return-to": "./src/return-to.ts",
|
|
15
12
|
"./mode-api": "./src/mode-api.ts",
|
|
16
13
|
"./workos-token": "./src/workos-token.ts"
|
|
@@ -20,9 +17,7 @@
|
|
|
20
17
|
"test": "tsc --noEmit && bun test test/"
|
|
21
18
|
},
|
|
22
19
|
"dependencies": {
|
|
23
|
-
"@metro-labs/core": "workspace:*"
|
|
24
|
-
"@noble/curves": "^1.9.7",
|
|
25
|
-
"viem": "2.52.2"
|
|
20
|
+
"@metro-labs/core": "workspace:*"
|
|
26
21
|
},
|
|
27
22
|
"devDependencies": {
|
|
28
23
|
"@stage-labs/config": "0.1.0-beta.2",
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { ApiError } from './api-error.js';
|
|
3
3
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
|
-
import { signedIdentity } from './signed-identity.js';
|
|
5
|
-
import { identitySubject } from './identity-registry.js';
|
|
6
4
|
|
|
7
5
|
const BODY_MAX = 4 * 1024;
|
|
8
6
|
|
|
@@ -29,7 +27,7 @@ export function cors(req: IncomingMessage): Record<string, string> {
|
|
|
29
27
|
return {
|
|
30
28
|
'access-control-allow-origin': req.headers.origin ?? '*',
|
|
31
29
|
'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
32
|
-
'access-control-allow-headers': 'Authorization, Content-Type
|
|
30
|
+
'access-control-allow-headers': 'Authorization, Content-Type',
|
|
33
31
|
'access-control-allow-private-network': 'true',
|
|
34
32
|
'access-control-max-age': '86400',
|
|
35
33
|
vary: 'Origin',
|
|
@@ -50,13 +48,8 @@ export function sendJson(
|
|
|
50
48
|
res.end(JSON.stringify(body));
|
|
51
49
|
}
|
|
52
50
|
|
|
53
|
-
export
|
|
54
|
-
|
|
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);
|
|
51
|
+
export function apiSession(req: IncomingMessage): Promise<ApiSession | null> {
|
|
52
|
+
return bearerSessions === null ? Promise.resolve(null) : bearerSessions(req);
|
|
60
53
|
}
|
|
61
54
|
|
|
62
55
|
export interface AgentIdentity {
|
package/runtime/runtime.json
CHANGED
package/runtime/stations.json
CHANGED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
-
import { verifyTypedData } from 'viem';
|
|
3
|
-
import { log } from '@metro-labs/core/log';
|
|
4
|
-
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
|
-
import { apiFailure, bodyField, cors, readJsonBody, sendJson } from '@metro-labs/http/api-http';
|
|
6
|
-
import { ENCRYPTION_KEY_TYPED_DATA, deriveIdentityAddress } from '@metro-labs/http/identity-key';
|
|
7
|
-
import { authorizeIdentity } from '@metro-labs/http/identity-registry';
|
|
8
|
-
|
|
9
|
-
const PATH = '/auth/identity';
|
|
10
|
-
const SIGNATURE_RE = /^0x[0-9a-fA-F]{130}$/;
|
|
11
|
-
|
|
12
|
-
export interface IdentityRouteDeps {
|
|
13
|
-
owner: () => string | null;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
async function register(req: IncomingMessage, deps: IdentityRouteDeps): Promise<{ address: string; owner: string }> {
|
|
17
|
-
const signature = bodyField(await readJsonBody(req), 'signature');
|
|
18
|
-
if (typeof signature !== 'string' || !SIGNATURE_RE.test(signature))
|
|
19
|
-
throw new ApiError('signature is required: the EncryptionKey signature of the owner wallet', 400);
|
|
20
|
-
const owner = deps.owner();
|
|
21
|
-
if (owner === null)
|
|
22
|
-
throw new ApiError('this machine has no owner yet: start it with metro serve --owner <address>', 403);
|
|
23
|
-
const ok = await verifyTypedData({
|
|
24
|
-
...ENCRYPTION_KEY_TYPED_DATA,
|
|
25
|
-
address: owner as `0x${string}`,
|
|
26
|
-
signature: signature as `0x${string}`,
|
|
27
|
-
}).catch(() => false);
|
|
28
|
-
if (!ok) throw new ApiError('this machine belongs to another wallet', 403);
|
|
29
|
-
const address = deriveIdentityAddress(signature as `0x${string}`);
|
|
30
|
-
authorizeIdentity(address, owner);
|
|
31
|
-
log.info({ owner, identity: address }, 'identity: owner signed in');
|
|
32
|
-
return { address, owner };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function handleIdentityRequest(req: IncomingMessage, res: ServerResponse, deps: IdentityRouteDeps): boolean {
|
|
36
|
-
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
37
|
-
if (path !== PATH) return false;
|
|
38
|
-
if (req.method === 'OPTIONS') {
|
|
39
|
-
res.writeHead(204, cors(req)).end();
|
|
40
|
-
return true;
|
|
41
|
-
}
|
|
42
|
-
if (req.method !== 'POST') {
|
|
43
|
-
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
44
|
-
return true;
|
|
45
|
-
}
|
|
46
|
-
register(req, deps)
|
|
47
|
-
.then((body) => {
|
|
48
|
-
sendJson(req, res, 200, body);
|
|
49
|
-
})
|
|
50
|
-
.catch((err: unknown) => {
|
|
51
|
-
apiFailure(req, res, err, 'identity');
|
|
52
|
-
});
|
|
53
|
-
return true;
|
|
54
|
-
}
|
|
@@ -1,49 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { hkdfSync } from 'node:crypto';
|
|
2
|
-
import { secp256k1 } from '@noble/curves/secp256k1';
|
|
3
|
-
import { mapHashToField } from '@noble/curves/abstract/modular';
|
|
4
|
-
import { privateKeyToAccount } from 'viem/accounts';
|
|
5
|
-
|
|
6
|
-
export const ENCRYPTION_KEY_TYPED_DATA = {
|
|
7
|
-
domain: { name: 'metro', version: '1' },
|
|
8
|
-
types: {
|
|
9
|
-
EncryptionKey: [
|
|
10
|
-
{ name: 'purpose', type: 'string' },
|
|
11
|
-
{ name: 'keyVersion', type: 'uint256' },
|
|
12
|
-
],
|
|
13
|
-
},
|
|
14
|
-
primaryType: 'EncryptionKey',
|
|
15
|
-
message: { purpose: 'encryption-key', keyVersion: 1n },
|
|
16
|
-
} as const;
|
|
17
|
-
|
|
18
|
-
const KEY_SALT = 'metro';
|
|
19
|
-
const IDENTITY_INFO = 'vault-secp256k1';
|
|
20
|
-
const KEY_HASH_BYTES = 48;
|
|
21
|
-
|
|
22
|
-
const toHex = (bytes: Uint8Array): `0x${string}` =>
|
|
23
|
-
`0x${[...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')}`;
|
|
24
|
-
|
|
25
|
-
export function deriveIdentityKey(signature: `0x${string}`): `0x${string}` {
|
|
26
|
-
const ikm = Buffer.from(signature.slice(2), 'hex');
|
|
27
|
-
const hash = new Uint8Array(hkdfSync('sha256', ikm, KEY_SALT, IDENTITY_INFO, KEY_HASH_BYTES));
|
|
28
|
-
return toHex(mapHashToField(hash, secp256k1.CURVE.n));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function deriveIdentityAddress(signature: `0x${string}`): string {
|
|
32
|
-
return privateKeyToAccount(deriveIdentityKey(signature)).address.toLowerCase();
|
|
33
|
-
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
const subjects = new Map<string, string>();
|
|
2
|
-
|
|
3
|
-
export function authorizeIdentity(address: string, subject: string): void {
|
|
4
|
-
subjects.set(address.toLowerCase(), subject.toLowerCase());
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export function identitySubject(address: string): string | undefined {
|
|
8
|
-
return subjects.get(address.toLowerCase());
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function resetIdentities(): void {
|
|
12
|
-
subjects.clear();
|
|
13
|
-
}
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import type { IncomingMessage } from 'node:http';
|
|
2
|
-
import { verifyMessage } from 'viem';
|
|
3
|
-
import { normalizeAddress } from '@metro-labs/core/address';
|
|
4
|
-
|
|
5
|
-
export const AUTH_SCHEME = 'Metro';
|
|
6
|
-
const SKEW_MS = 5 * 60_000;
|
|
7
|
-
const SIGNATURE_RE = /^0x[0-9a-fA-F]{130}$/;
|
|
8
|
-
|
|
9
|
-
export const identityChallenge = (method: string, path: string, at: number): string =>
|
|
10
|
-
`metro-auth\n${method} ${path}\n${String(at)}`;
|
|
11
|
-
|
|
12
|
-
export interface SignedHeader {
|
|
13
|
-
address: string;
|
|
14
|
-
at: number;
|
|
15
|
-
signature: `0x${string}`;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function parseIdentityHeader(header: string): SignedHeader | null {
|
|
19
|
-
const [scheme, rawAddress, rawAt, signature] = header.trim().split(/\s+/);
|
|
20
|
-
if (scheme !== AUTH_SCHEME || rawAddress === undefined || rawAt === undefined || signature === undefined) return null;
|
|
21
|
-
const address = normalizeAddress(rawAddress);
|
|
22
|
-
const at = Number(rawAt);
|
|
23
|
-
if (address === null || !Number.isFinite(at) || !SIGNATURE_RE.test(signature)) return null;
|
|
24
|
-
return { address, at, signature: signature as `0x${string}` };
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export async function signedIdentity(req: IncomingMessage, now = Date.now()): Promise<string | null> {
|
|
28
|
-
const proof = parseIdentityHeader(req.headers.authorization ?? '');
|
|
29
|
-
if (proof === null || Math.abs(now - proof.at) > SKEW_MS) return null;
|
|
30
|
-
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
31
|
-
const message = identityChallenge(req.method ?? '', path, proof.at);
|
|
32
|
-
const ok = await verifyMessage({ address: proof.address as `0x${string}`, message, signature: proof.signature }).catch(
|
|
33
|
-
() => false,
|
|
34
|
-
);
|
|
35
|
-
return ok ? proof.address : null;
|
|
36
|
-
}
|