@stage-labs/metro 0.1.0-beta.137 → 0.1.0-beta.139

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 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 (or wallet) that owns it once; it is remembered in ' +
67
+ 'Pass the organization that owns it once; it is remembered in ' +
68
68
  join(dir, '.owner') +
69
- ':\n metro serve --owner <org_… or address>');
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.137",
3
+ "version": "0.1.0-beta.139",
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.137",
3
+ "version": "0.1.0-beta.139",
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",
@@ -33,7 +33,6 @@
33
33
  "@metro-labs/whatsapp": "workspace:*",
34
34
  "@metro-labs/xmtp": "workspace:*",
35
35
  "@modelcontextprotocol/sdk": "^1.12.0",
36
- "viem": "2.52.2",
37
36
  "ws": "^8.18.0",
38
37
  "zod": "^3.23.8"
39
38
  },
@@ -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
- ADDRESS_RE,
102
+ OWNER_RE,
102
103
  path,
103
- 'owner is not a lowercase Ethereum address',
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 (stored === null) log.warn('local daemon: no owner set, so nobody can sign in; restart with metro serve --owner <organization id or address>');
13
- return stored;
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
 
@@ -31,6 +31,7 @@ import {
31
31
  } from '../connectors/store.js';
32
32
  import {
33
33
  assertLocalOwner,
34
+ setLocalOwner,
34
35
  LOCAL_PROJECT_ID,
35
36
  localAttachAccount,
36
37
  localDeleteAgent,
@@ -43,7 +44,6 @@ import {
43
44
  localOwner,
44
45
  localResetAgentKey,
45
46
  readLocalAgentFile,
46
- setLocalOwner,
47
47
  } from '../agents/file-admin.js';
48
48
  import { listAgentFiles, readAgentFile } from '../agents/files.js';
49
49
  import { readModelConfig } from '../gateway/model-config.js';
@@ -214,12 +214,11 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
214
214
  claudeApi: { authorize: (subject) => { assertLocalOwner(subject); } },
215
215
  updateApi: { authorize: (subject) => { assertLocalOwner(subject); }, restart: deps.restart },
216
216
  controlApi: { authorize: (subject) => { assertLocalOwner(subject); }, restart: deps.restart, stop: deps.stop },
217
+ ownerApi: { authorize: (subject) => { assertLocalOwner(subject); }, setOwner: (owner) => setLocalOwner(owner) },
217
218
  machineApi: { authorize: (subject) => { assertLocalOwner(subject); } },
218
219
  modelApi: { authorize: (subject) => { assertLocalOwner(subject); } },
219
220
  gateway: { config: readModelConfig },
220
221
  terminalApi: { authorize: (subject) => { assertLocalOwner(subject); } },
221
- identity: { owner: localOwner },
222
- owner: { owner: localOwner, setOwner: setLocalOwner },
223
222
  mode: localModeInfo,
224
223
  };
225
224
  }
@@ -6,14 +6,13 @@ 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';
14
12
  import { handleBundleRequest, type BundleApiDeps } from '../agents/bundle.js';
15
13
  import { handleUpdateRequest, type UpdateApiDeps } from '../server/update.js';
16
14
  import { handleControlRequest, type ControlApiDeps } from '../server/control.js';
15
+ import { handleOwnerRequest, type OwnerApiDeps } from '../server/owner.js';
17
16
  import { handleMachineRequest, type MachineApiDeps } from '../server/machine.js';
18
17
  import { handleModelRequest, type ModelApiDeps } from '../gateway/model-api.js';
19
18
  import type { GatewayDeps } from '../gateway/gateway.js';
@@ -24,6 +23,7 @@ export interface SessionApis {
24
23
  bundleApi?: BundleApiDeps;
25
24
  updateApi?: UpdateApiDeps;
26
25
  controlApi?: ControlApiDeps;
26
+ ownerApi?: OwnerApiDeps;
27
27
  machineApi?: MachineApiDeps;
28
28
  modelApi?: ModelApiDeps;
29
29
  gateway?: GatewayDeps;
@@ -32,8 +32,6 @@ export interface SessionApis {
32
32
  claudeApi?: ClaudeApiDeps;
33
33
  connectorApi?: ConnectorApiDeps;
34
34
  relayApi?: RelayApiDeps;
35
- identity?: IdentityRouteDeps;
36
- owner?: OwnerRouteDeps;
37
35
  mode?: () => ModeInfo;
38
36
  }
39
37
 
@@ -52,6 +50,7 @@ export function handleSessionApis(
52
50
  ...when(apis.bundleApi, (d) => handleBundleRequest(req, res, d)),
53
51
  ...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
54
52
  ...when(apis.controlApi, (d) => handleControlRequest(req, res, d)),
53
+ ...when(apis.ownerApi, (d) => handleOwnerRequest(req, res, d)),
55
54
  ...when(apis.machineApi, (d) => handleMachineRequest(req, res, d)),
56
55
  ...when(apis.modelApi, (d) => handleModelRequest(req, res, d)),
57
56
  ...when(apis.terminalApi, (d) => handleTerminalRequest(req, res, d)),
@@ -0,0 +1,48 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { ApiError } from '@metro-labs/http/api-error';
3
+ import { apiFailure, apiSession, cors, readJsonBody, requireAdmin, sendJson } from '@metro-labs/http/api-http';
4
+ import { isOrganizationId } from '@metro-labs/http/workos-token';
5
+ import { isRecord } from '@metro-labs/core/is-record';
6
+ import { log } from '@metro-labs/core/log';
7
+
8
+ const PATH = '/api/owner';
9
+
10
+ export interface OwnerApiDeps {
11
+ authorize: (subject: string) => void;
12
+ setOwner: (owner: string) => string;
13
+ }
14
+
15
+ async function move(req: IncomingMessage, deps: OwnerApiDeps): Promise<{ owner: string }> {
16
+ const session = await apiSession(req);
17
+ if (!session) throw new ApiError('unauthorized', 401);
18
+ deps.authorize(session.subject);
19
+ requireAdmin(session);
20
+ const body = await readJsonBody(req);
21
+ const owner = isRecord(body) && typeof body.owner === 'string' ? body.owner.trim() : '';
22
+ if (!isOrganizationId(owner)) throw new ApiError('owner must be an organization id', 400);
23
+ if (owner === session.subject) throw new ApiError('this machine already belongs to that organization', 409);
24
+ const saved = deps.setOwner(owner);
25
+ log.info({ from: session.subject, to: saved }, 'owner: this machine moved to another organization');
26
+ return { owner: saved };
27
+ }
28
+
29
+ export function handleOwnerRequest(req: IncomingMessage, res: ServerResponse, deps: OwnerApiDeps): boolean {
30
+ const path = (req.url ?? '').split('?')[0] ?? '';
31
+ if (path !== PATH) return false;
32
+ if (req.method === 'OPTIONS') {
33
+ res.writeHead(204, cors(req)).end();
34
+ return true;
35
+ }
36
+ if (req.method !== 'POST') {
37
+ sendJson(req, res, 405, { error: 'method not allowed' });
38
+ return true;
39
+ }
40
+ move(req, deps)
41
+ .then((body) => {
42
+ sendJson(req, res, 200, body);
43
+ })
44
+ .catch((err: unknown) => {
45
+ apiFailure(req, res, err, 'owner-api');
46
+ });
47
+ return true;
48
+ }
@@ -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, X-Metro-Wallet',
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 async function apiSession(req: IncomingMessage): Promise<ApiSession | null> {
54
- const address = await signedIdentity(req);
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 {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.137"
2
+ "version": "0.1.0-beta.139"
3
3
  }
@@ -1,10 +1,8 @@
1
1
  {
2
2
  "core": {
3
3
  "@modelcontextprotocol/sdk": "^1.12.0",
4
- "@noble/curves": "^1.9.7",
5
4
  "pino": "^9.5.0",
6
5
  "pino-pretty": "^13.1.3",
7
- "viem": "2.52.2",
8
6
  "ws": "^8.18.0",
9
7
  "zod": "^3.23.8"
10
8
  },
@@ -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
- }