@stage-labs/metro 0.1.0-beta.135 → 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.
Files changed (22) hide show
  1. package/dist/serve.js +11 -6
  2. package/package.json +1 -1
  3. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  4. package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +2 -1
  5. package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +12 -6
  6. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +2 -0
  7. package/runtime/node_modules/@metro-labs/daemon/src/boot/local-owner.ts +1 -1
  8. package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +4 -5
  9. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +3 -1
  10. package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +35 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +2 -0
  12. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -0
  13. package/runtime/node_modules/@metro-labs/daemon/src/routes/owner.ts +49 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +2 -0
  15. package/runtime/node_modules/@metro-labs/daemon/src/routes/session.ts +1 -1
  16. package/runtime/node_modules/@metro-labs/daemon/src/server/control.ts +2 -1
  17. package/runtime/node_modules/@metro-labs/daemon/src/server/update.ts +2 -1
  18. package/runtime/node_modules/@metro-labs/daemon/src/terminal/api.ts +2 -1
  19. package/runtime/node_modules/@metro-labs/http/package.json +2 -1
  20. package/runtime/node_modules/@metro-labs/http/src/api-http.ts +20 -2
  21. package/runtime/node_modules/@metro-labs/http/src/workos-token.ts +150 -0
  22. 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 not an Ethereum address — ${USAGE}`);
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 no wallet could sign in.\n' +
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().toLowerCase();
71
- return ADDRESS.test(raw) ? raw : null;
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.135",
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.135",
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 normalizeAddress(readFileSync(join(dir, OWNER_FILE), 'utf8'));
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 address = normalizeAddress(raw);
51
- if (address === null) throw new ApiError(`'${raw}' is not an Ethereum address`, 400);
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), `${address}\n`);
54
- return address;
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 === normalizeAddress(subject);
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 no wallet can sign in; restart with metro serve --owner <address>');
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,11 +361,6 @@ export class InboundRelay {
357
361
  await this.handleReact(ev, base);
358
362
  return;
359
363
  }
360
- if (base.evType === 'msg' && base.text.trim() === '') {
361
- this.deps.log('drop: empty message', base.station, base.line, str(ev.messageId));
362
- return;
363
- }
364
-
365
364
  await this.emitMessage(ev, base);
366
365
  }
367
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
- const subject = address === null ? undefined : identitySubject(address);
41
- return subject === undefined ? null : { subject };
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
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.135"
2
+ "version": "0.1.0-beta.136"
3
3
  }