@stage-labs/metro 0.1.0-beta.252 → 0.1.0-beta.254

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 (29) hide show
  1. package/package.json +2 -2
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/node_modules/@metro-labs/core/package.json +1 -1
  4. package/runtime/node_modules/@metro-labs/daemon/package.json +1 -1
  5. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +9 -1
  6. package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +52 -18
  7. package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +5 -4
  8. package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-logins.ts +168 -0
  9. package/runtime/node_modules/@metro-labs/daemon/src/gateway/effort.ts +14 -4
  10. package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +9 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +58 -13
  12. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +12 -4
  13. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +21 -2
  14. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +2 -0
  15. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +5 -1
  16. package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +1 -2
  17. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +16 -5
  18. package/runtime/node_modules/@metro-labs/discord-bot/package.json +1 -1
  19. package/runtime/node_modules/@metro-labs/gmail/package.json +1 -1
  20. package/runtime/node_modules/@metro-labs/http/package.json +1 -1
  21. package/runtime/node_modules/@metro-labs/http/src/return-to.ts +4 -0
  22. package/runtime/node_modules/@metro-labs/outlook/package.json +1 -1
  23. package/runtime/node_modules/@metro-labs/telegram/package.json +1 -1
  24. package/runtime/node_modules/@metro-labs/telegram-bot/package.json +1 -1
  25. package/runtime/node_modules/@metro-labs/threema/package.json +1 -1
  26. package/runtime/node_modules/@metro-labs/webhook/package.json +1 -1
  27. package/runtime/node_modules/@metro-labs/whatsapp/package.json +1 -1
  28. package/runtime/node_modules/@metro-labs/xmtp/package.json +1 -1
  29. package/runtime/runtime.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.252",
3
+ "version": "0.1.0-beta.254",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,7 +30,7 @@
30
30
  "test": "tsc --noEmit && node scripts/stage-runtime.mjs && bun test test/"
31
31
  },
32
32
  "devDependencies": {
33
- "@stage-labs/config": "0.1.0-beta.2",
33
+ "@stage-labs/config": "0.1.0-beta.3",
34
34
  "eslint": "^10.3.0",
35
35
  "knip": "^6.15.0",
36
36
  "madge": "^8.0.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.252",
3
+ "version": "0.1.0-beta.254",
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, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -42,7 +42,7 @@
42
42
  "pino-pretty": "^13.1.3"
43
43
  },
44
44
  "devDependencies": {
45
- "@stage-labs/config": "0.1.0-beta.2",
45
+ "@stage-labs/config": "0.1.0-beta.3",
46
46
  "eslint": "^10.3.0",
47
47
  "knip": "^6.15.0",
48
48
  "madge": "^8.0.0",
@@ -41,7 +41,7 @@
41
41
  "zod": "^3.23.8"
42
42
  },
43
43
  "devDependencies": {
44
- "@stage-labs/config": "0.1.0-beta.2",
44
+ "@stage-labs/config": "0.1.0-beta.3",
45
45
  "@types/ws": "^8.18.0",
46
46
  "eslint": "^10.3.0",
47
47
  "knip": "^6.15.0",
@@ -19,7 +19,9 @@ import {
19
19
  endClaudeLogin,
20
20
  startClaudeLogin,
21
21
  type LoginDeps,
22
+ type LoginTarget,
22
23
  } from './login.js';
24
+ import { claudeLoginTarget, type ClaudeLoginDeps } from '../gateway/claude-logins.js';
23
25
  import {
24
26
  claudeSetupStatus,
25
27
  ensureClaudeSetup,
@@ -71,6 +73,7 @@ const PAGE_MAX = 500;
71
73
  export interface ClaudeApiDeps {
72
74
  dir?: () => string;
73
75
  login?: LoginDeps;
76
+ claudeLogins?: ClaudeLoginDeps;
74
77
  session?: SessionDeps;
75
78
  setup?: SetupDeps;
76
79
  version?: VersionDeps;
@@ -265,10 +268,15 @@ async function sessionAnswer(req: IncomingMessage, deps: ClaudeApiDeps): Promise
265
268
  return sessionCommand(body, session);
266
269
  }
267
270
 
271
+ function loginTarget(req: IncomingMessage, deps: ClaudeApiDeps): LoginTarget | null {
272
+ const asked = new URL(req.url ?? '', 'http://metro').searchParams.get('connection');
273
+ return asked === null ? null : claudeLoginTarget(asked, deps.claudeLogins);
274
+ }
275
+
268
276
  async function loginAnswer(req: IncomingMessage, id: string, deps: ClaudeApiDeps): Promise<unknown> {
269
277
  const method = req.method ?? 'GET';
270
278
  if (id === '') {
271
- if (method === 'POST') return startClaudeLogin(deps.login);
279
+ if (method === 'POST') return startClaudeLogin(deps.login, Date.now(), loginTarget(req, deps));
272
280
  if (method === 'GET') return { available: claudeInstalled(), ...claudeAccount() };
273
281
  throw new ApiError('method not allowed', 405);
274
282
  }
@@ -26,12 +26,19 @@ export interface LoginView {
26
26
  error: string | null;
27
27
  }
28
28
 
29
+ export interface LoginTarget {
30
+ key: string;
31
+ env: Record<string, string>;
32
+ settle: (ok: boolean) => Promise<string | null>;
33
+ }
34
+
29
35
  interface Live {
30
36
  id: string;
31
37
  startedAt: number;
32
38
  state: LoginState;
33
39
  output: string;
34
40
  error: string | null;
41
+ target: LoginTarget | null;
35
42
  write: (text: string) => void;
36
43
  kill: () => void;
37
44
  }
@@ -102,27 +109,32 @@ export function claudeInstalled(): boolean {
102
109
  });
103
110
  }
104
111
 
105
- interface ClaudeAccount {
112
+ export interface ClaudeAccount {
106
113
  signedIn: boolean;
107
114
  account: string | null;
115
+ plan: string | null;
108
116
  }
109
117
 
118
+ const SIGNED_OUT: ClaudeAccount = { signedIn: false, account: null, plan: null };
119
+
110
120
  export function claudeAccount(): ClaudeAccount {
111
- return remembered(checks.account, readAccount, (c) => {
121
+ return remembered(checks.account, () => claudeAccountIn({}), (c) => {
112
122
  checks.account = c;
113
123
  });
114
124
  }
115
125
 
116
- function readAccount(): ClaudeAccount {
117
- const run = spawnSync(...asAgent(claudeBin(), STATUS_COMMAND), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
118
- if (run.error !== undefined || typeof run.stdout !== 'string') return { signedIn: false, account: null };
126
+ const textOf = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
127
+
128
+ export function claudeAccountIn(env: Record<string, string>): ClaudeAccount {
129
+ const run = spawnSync(...asAgent(claudeBin(), STATUS_COMMAND, env), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: { ...process.env, ...env } });
130
+ if (run.error !== undefined || typeof run.stdout !== 'string') return SIGNED_OUT;
119
131
  try {
120
132
  const parsed: unknown = JSON.parse(run.stdout);
121
- if (!isRecord(parsed)) return { signedIn: false, account: null };
122
- const account = typeof parsed.email === 'string' ? parsed.email : typeof parsed.organization === 'string' ? parsed.organization : null;
123
- return { signedIn: parsed.authenticated === true || parsed.loggedIn === true || account !== null, account };
133
+ if (!isRecord(parsed)) return SIGNED_OUT;
134
+ const account = textOf(parsed.email) ?? textOf(parsed.organization) ?? textOf(parsed.orgName);
135
+ return { signedIn: parsed.authenticated === true || parsed.loggedIn === true || account !== null, account, plan: textOf(parsed.subscriptionType) };
124
136
  } catch {
125
- return { signedIn: false, account: null };
137
+ return SIGNED_OUT;
126
138
  }
127
139
  }
128
140
 
@@ -131,16 +143,37 @@ function note(session: Live, chunk: string): void {
131
143
  session.output = next.length > OUTPUT_MAX ? next.slice(next.length - OUTPUT_MAX) : next;
132
144
  }
133
145
 
134
- export function startClaudeLogin(deps: LoginDeps = {}, now = Date.now()): LoginView {
146
+ async function verdict(session: Live, code: number): Promise<string | null> {
147
+ const problem = code === 0 ? null : `the login ended with status ${String(code)}`;
148
+ if (session.target === null) return problem;
149
+ try {
150
+ return (await session.target.settle(problem === null)) ?? problem;
151
+ } catch (err) {
152
+ return errMsg(err);
153
+ }
154
+ }
155
+
156
+ function runningFor(target: LoginTarget | null): Live | null {
157
+ if (live === null || live.state !== 'pending') return null;
158
+ if (live.target?.key === target?.key) return live;
159
+ live.kill();
160
+ live = null;
161
+ return null;
162
+ }
163
+
164
+ export function startClaudeLogin(deps: LoginDeps = {}, now = Date.now(), target: LoginTarget | null = null): LoginView {
135
165
  sweep(now);
136
- if (live !== null && live.state === 'pending') return view(live);
137
- const command = deps.command ?? agentCommand([claudeBin(), ...LOGIN_ARGS], { TERM: 'xterm-256color' });
166
+ const running = runningFor(target);
167
+ if (running !== null) return view(running);
168
+ const env = { TERM: 'xterm-256color', ...target?.env };
169
+ const command = deps.command ?? agentCommand([claudeBin(), ...LOGIN_ARGS], env);
138
170
  const session: Live = {
139
171
  id: randomBytes(9).toString('base64url'),
140
172
  startedAt: now,
141
173
  state: 'pending',
142
174
  output: '',
143
175
  error: null,
176
+ target,
144
177
  write: () => undefined,
145
178
  kill: () => undefined,
146
179
  };
@@ -151,7 +184,7 @@ export function startClaudeLogin(deps: LoginDeps = {}, now = Date.now()): LoginV
151
184
  note(session, Buffer.from(chunk).toString('utf8'));
152
185
  },
153
186
  });
154
- const proc = Bun.spawn(command, { terminal, env: { ...process.env, TERM: 'xterm-256color' } });
187
+ const proc = Bun.spawn(command, { terminal, env: { ...process.env, ...env } });
155
188
  session.write = (text: string) => {
156
189
  terminal.write(Buffer.from(`${text}\r`, 'utf8'));
157
190
  };
@@ -160,13 +193,14 @@ export function startClaudeLogin(deps: LoginDeps = {}, now = Date.now()): LoginV
160
193
  terminal.close();
161
194
  };
162
195
  proc.exited
163
- .then((code) => {
164
- session.state = code === 0 ? 'done' : 'failed';
165
- if (code !== 0) session.error = `the login ended with status ${String(code)}`;
196
+ .then(async (code) => {
197
+ session.write = () => undefined;
166
198
  terminal.close();
167
199
  forgetClaudeChecks();
168
- const onboarding = code === 0 ? markOnboardingDone() : 'skipped';
169
- log.info({ state: session.state, onboarding }, 'claude-login: the official login finished');
200
+ session.error = await verdict(session, code);
201
+ session.state = session.error === null ? 'done' : 'failed';
202
+ const onboarding = session.error === null ? markOnboardingDone() : 'skipped';
203
+ log.info({ state: session.state, onboarding, own: session.target !== null }, 'claude-login: the official login finished');
170
204
  })
171
205
  .catch((err: unknown) => {
172
206
  session.state = 'failed';
@@ -35,11 +35,12 @@ const LINE_MAX = 1024 * 1024;
35
35
  export interface UsageProbeDeps {
36
36
  command?: string[];
37
37
  timeoutMs?: number;
38
+ env?: Record<string, string>;
38
39
  }
39
40
 
40
- export function usageProbeCommand(): string[] | null {
41
+ export function usageProbeCommand(env: Record<string, string> = {}): string[] | null {
41
42
  if (runningAsRoot() || (agentUserExpected() && agentUser() === null)) return null;
42
- return agentCommand([claudeBin(), ...ARGS], PROBE_ENV);
43
+ return agentCommand([claudeBin(), ...ARGS], { ...PROBE_ENV, ...env });
43
44
  }
44
45
 
45
46
  function answerOf(line: string): Record<string, unknown> | null {
@@ -67,11 +68,11 @@ export function usageIn(output: string): unknown {
67
68
  }
68
69
 
69
70
  export function readClaudeUsage(deps: UsageProbeDeps = {}): Promise<unknown> {
70
- const command = deps.command ?? usageProbeCommand();
71
+ const command = deps.command ?? usageProbeCommand(deps.env);
71
72
  if (command === null) return Promise.reject(new Error('Claude Code runs only as the agent user, and this box has none'));
72
73
  const [file = '', ...args] = command;
73
74
  return new Promise((resolve, reject) => {
74
- const child = spawn(file, args, { cwd: '/', stdio: ['pipe', 'pipe', 'ignore'], env: { ...process.env, ...PROBE_ENV } });
75
+ const child = spawn(file, args, { cwd: '/', stdio: ['pipe', 'pipe', 'ignore'], env: { ...process.env, ...PROBE_ENV, ...deps.env } });
75
76
  let carry = '';
76
77
  let settled = false;
77
78
  let hardKill: ReturnType<typeof setTimeout> | undefined;
@@ -0,0 +1,168 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { ID_RE, newId } from '@metro-labs/core/ids';
4
+ import { isRecord } from '@metro-labs/core/is-record';
5
+ import { errMsg, log } from '@metro-labs/core/log';
6
+ import { ApiError } from '@metro-labs/http/api-error';
7
+ import { readFileSync } from '../agent-user/agent-fs.js';
8
+ import { removeHome } from '../agent-user/home-fs.js';
9
+ import { claudeHome } from '../agent-user/user.js';
10
+ import { claudeAccountIn, type ClaudeAccount, type LoginTarget } from '../claude/login.js';
11
+ import { readClaudeUsage } from '../claude/usage-probe.js';
12
+ import { currentOf, reach, refreshed, type TokenSource, type TokenState } from './subscription.js';
13
+ import { forgetReported } from './usage.js';
14
+ import { addConnection, connectionOf, readModelConfig, setClaudeLogin, writeModelConfig, type ModelConfig } from './model-config.js';
15
+
16
+ const CREDENTIALS = '.credentials.json';
17
+ const RENEW_MARGIN_MS = 60_000;
18
+ const NEW = 'new';
19
+ const MISSING = 'this Claude login is no longer on the box: sign in again on the Model page';
20
+
21
+ export interface ClaudeLoginDeps {
22
+ root?: string;
23
+ probe?: (env: Record<string, string>) => Promise<unknown>;
24
+ account?: (env: Record<string, string>) => ClaudeAccount;
25
+ read?: () => ModelConfig;
26
+ write?: (cfg: ModelConfig) => void;
27
+ }
28
+
29
+ interface ClaudeTokens {
30
+ accessToken: string;
31
+ expiresAt: number | null;
32
+ savedAt: string;
33
+ }
34
+
35
+ const state: TokenState<ClaudeTokens> = new Map();
36
+ const seen = new Map<string, ClaudeTokens>();
37
+
38
+ const claudeLoginsRoot = (): string => join(claudeHome() ?? homedir(), '.metro', 'claude-logins');
39
+
40
+ const rootOf = (deps: ClaudeLoginDeps): string => deps.root ?? claudeLoginsRoot();
41
+
42
+ function claudeLoginEnv(login: string, deps: ClaudeLoginDeps = {}): Record<string, string> {
43
+ if (!ID_RE.test(login)) throw new Error('not a Claude login id');
44
+ return { CLAUDE_CONFIG_DIR: join(rootOf(deps), login) };
45
+ }
46
+
47
+ export const probeClaudeLogin = (login: string, deps: ClaudeLoginDeps = {}): Promise<unknown> =>
48
+ (deps.probe ?? ((env) => readClaudeUsage({ env })))(claudeLoginEnv(login, deps));
49
+
50
+ const stale = (tokens: ClaudeTokens, margin = RENEW_MARGIN_MS): boolean => tokens.expiresAt !== null && tokens.expiresAt - margin <= Date.now();
51
+
52
+ function onDisk(login: string, deps: ClaudeLoginDeps): ClaudeTokens | null {
53
+ let raw: unknown;
54
+ try {
55
+ raw = JSON.parse(readFileSync(join(claudeLoginEnv(login, deps).CLAUDE_CONFIG_DIR ?? '', CREDENTIALS), 'utf8'));
56
+ } catch {
57
+ return null;
58
+ }
59
+ const oauth = isRecord(raw) && isRecord(raw.claudeAiOauth) ? raw.claudeAiOauth : {};
60
+ const accessToken = typeof oauth.accessToken === 'string' ? oauth.accessToken : '';
61
+ const expiresAt = typeof oauth.expiresAt === 'number' && Number.isFinite(oauth.expiresAt) ? oauth.expiresAt : null;
62
+ return accessToken === '' ? null : { accessToken, expiresAt, savedAt: new Date(expiresAt ?? 0).toISOString() };
63
+ }
64
+
65
+ function remembered(login: string, tokens: ClaudeTokens | null): ClaudeTokens | null {
66
+ if (tokens === null) seen.delete(login);
67
+ else seen.set(login, tokens);
68
+ return tokens;
69
+ }
70
+
71
+ function known(login: string, deps: ClaudeLoginDeps): ClaudeTokens | null {
72
+ const kept = seen.get(login);
73
+ if (kept !== undefined && !stale(kept)) return kept;
74
+ const read = onDisk(login, deps);
75
+ return read === null && kept !== undefined ? kept : remembered(login, read);
76
+ }
77
+
78
+ function sourceFor(login: string, deps: ClaudeLoginDeps): TokenSource<ClaudeTokens> {
79
+ return {
80
+ label: 'Claude',
81
+ stale: (tokens) => stale(tokens),
82
+ refresh: async () => {
83
+ try {
84
+ await probeClaudeLogin(login, deps);
85
+ } catch (err) {
86
+ log.warn({ err: errMsg(err) }, 'claude-logins: Claude Code stumbled while renewing a login, so its folder is read as it is');
87
+ }
88
+ const fresh = onDisk(login, deps);
89
+ if (fresh === null || stale(fresh, 0)) throw new Error('Claude Code could not renew this login');
90
+ return fresh;
91
+ },
92
+ save: (id, tokens) => {
93
+ remembered(id, tokens);
94
+ },
95
+ };
96
+ }
97
+
98
+ function again(login: string, failed: ClaudeTokens, deps: ClaudeLoginDeps, source: TokenSource<ClaudeTokens>): Promise<ClaudeTokens> {
99
+ const disk = onDisk(login, deps);
100
+ if (disk !== null && disk.accessToken !== failed.accessToken && !stale(disk)) return Promise.resolve(remembered(login, disk) ?? disk);
101
+ return refreshed(state, login, failed, source);
102
+ }
103
+
104
+ export function withClaudeLogin(login: string, deps: ClaudeLoginDeps, send: (token: string) => Promise<Response>): Promise<Response> {
105
+ const source = sourceFor(login, deps);
106
+ return reach(
107
+ () => currentOf(state, login, known(login, deps), source, MISSING),
108
+ (failed) => again(login, failed, deps, source),
109
+ (tokens) => send(tokens.accessToken),
110
+ );
111
+ }
112
+
113
+ function dropClaudeLogin(login: string, deps: ClaudeLoginDeps): void {
114
+ seen.delete(login);
115
+ state.delete(login);
116
+ try {
117
+ removeHome(claudeLoginEnv(login, deps).CLAUDE_CONFIG_DIR ?? '', true);
118
+ } catch (err) {
119
+ log.warn({ err: errMsg(err) }, 'claude-logins: could not remove a Claude login that is no longer used');
120
+ }
121
+ }
122
+
123
+ export function dropUnusedClaudeLogins(before: ModelConfig, after: ModelConfig, deps: ClaudeLoginDeps = {}): void {
124
+ const kept = new Set(after.connections.flatMap((c) => (c.claude === null ? [] : [c.claude.id])));
125
+ for (const c of before.connections) if (c.claude !== null && !kept.has(c.claude.id)) dropClaudeLogin(c.claude.id, deps);
126
+ }
127
+
128
+ function keep(login: string, connection: string | null, account: ClaudeAccount, deps: ClaudeLoginDeps): string | null {
129
+ const before = (deps.read ?? readModelConfig)();
130
+ const cfg = connection === null ? addConnection(before, { provider: 'anthropic' }) : before;
131
+ const id = connection ?? cfg.connections.at(-1)?.id ?? '';
132
+ if (connectionOf(cfg, id)?.provider !== 'anthropic') return 'that connection was removed during the sign-in';
133
+ const next = setClaudeLogin(cfg, id, { id: login, email: account.account, plan: account.plan, savedAt: new Date().toISOString() });
134
+ (deps.write ?? writeModelConfig)(next);
135
+ dropUnusedClaudeLogins(before, next, deps);
136
+ forgetReported(id);
137
+ log.info({ connection: id, plan: account.plan, added: connection === null }, 'claude-logins: the Claude login was kept for its own connection');
138
+ return null;
139
+ }
140
+
141
+ function settle(ok: boolean, login: string, connection: string | null, deps: ClaudeLoginDeps): Promise<string | null> {
142
+ if (!ok) {
143
+ dropClaudeLogin(login, deps);
144
+ return Promise.resolve(null);
145
+ }
146
+ let problem: string | null;
147
+ try {
148
+ const account = (deps.account ?? claudeAccountIn)(claudeLoginEnv(login, deps));
149
+ problem = account.signedIn ? keep(login, connection, account, deps) : 'the sign-in finished but Claude Code holds no login; try again';
150
+ } catch (err) {
151
+ problem = errMsg(err);
152
+ }
153
+ if (problem !== null) dropClaudeLogin(login, deps);
154
+ return Promise.resolve(problem);
155
+ }
156
+
157
+ export function claudeLoginTarget(asked: string, deps: ClaudeLoginDeps = {}): LoginTarget {
158
+ const connection = asked === NEW ? null : asked;
159
+ if (connection !== null && connectionOf((deps.read ?? readModelConfig)(), connection)?.provider !== 'anthropic')
160
+ throw new ApiError('no such Anthropic connection', 404);
161
+ const login = newId();
162
+ return { key: asked, env: claudeLoginEnv(login, deps), settle: (ok) => settle(ok, login, connection, deps) };
163
+ }
164
+
165
+ export function forgetClaudeTokens(): void {
166
+ seen.clear();
167
+ state.clear();
168
+ }
@@ -38,16 +38,26 @@ export function requestKind(req: IncomingMessage, body: Body): RequestKind {
38
38
  return chore(body) ? 'other' : 'main';
39
39
  }
40
40
 
41
+ const messagesOf = (body: Body): unknown[] => (Array.isArray(body.messages) ? body.messages : []);
42
+
43
+ const askedEfforts = (body: Body): string[] => [body, ...messagesOf(body).map(record)].map(effortOf).filter((effort) => effort !== '');
44
+
41
45
  export function plannedEffort(req: IncomingMessage, body: Body): string | null {
42
- const current = effortOf(body);
43
- if (current === '') return null;
46
+ const asked = askedEfforts(body);
47
+ if (asked.length === 0) return null;
44
48
  const kind = requestKind(req, body);
45
49
  if (kind === 'other') return null;
46
50
  const wanted = kind === 'subagent' ? SUBAGENT_EFFORT : MAIN_EFFORT;
47
- return wanted === current ? null : wanted;
51
+ return asked.every((effort) => effort === wanted) ? null : wanted;
48
52
  }
49
53
 
50
- export const withEffort = (body: Body, effort: string): Body => ({ ...body, output_config: { ...record(body.output_config), effort } });
54
+ const withOwnEffort = (item: Body, effort: string): Body => (effortOf(item) === '' ? item : { ...item, output_config: { ...record(item.output_config), effort } });
55
+
56
+ export function withEffort(body: Body, effort: string): Body {
57
+ const top = withOwnEffort(body, effort);
58
+ if (!Array.isArray(body.messages)) return top;
59
+ return { ...top, messages: body.messages.map((message: unknown) => (isRecord(message) ? withOwnEffort(message, effort) : message)) };
60
+ }
51
61
 
52
62
  export function withoutEffort(body: Body): Body {
53
63
  const config = Object.fromEntries(Object.entries(record(body.output_config)).filter(([key]) => key !== 'effort'));
@@ -128,6 +128,15 @@ export function anthropicHeaders(req: IncomingMessage, apiKey: string): Record<s
128
128
  return headers;
129
129
  }
130
130
 
131
+ export const OAUTH_BETA = 'oauth-2025-04-20';
132
+
133
+ export function loginHeaders(req: IncomingMessage, token: string): Record<string, string> {
134
+ const headers = forwardedHeaders(req);
135
+ delete headers['x-api-key'];
136
+ headers.authorization = `Bearer ${token}`;
137
+ return addBeta(headers, OAUTH_BETA);
138
+ }
139
+
131
140
  export function addBeta(headers: Record<string, string>, beta: string): Record<string, string> {
132
141
  const listed = (headers['anthropic-beta'] ?? '')
133
142
  .split(',')
@@ -1,4 +1,4 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http';
1
+ import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { errMsg, log } from '@metro-labs/core/log';
3
3
  import { cameThroughProxy } from '@metro-labs/http/api-http';
4
4
  import { agentIdForKey } from '../agents/keys.js';
@@ -10,7 +10,20 @@ import {
10
10
  freshAdaptations,
11
11
  type Adaptations,
12
12
  } from './bedrock.js';
13
- import { addBeta, anthropicHeaders, forwardedHeaders, GatewayError, holdLimitRefusal, parseJson, pipeResponse, readBody, sendError, upstreamMessage, watchUpstream } from './forward.js';
13
+ import {
14
+ addBeta,
15
+ anthropicHeaders,
16
+ forwardedHeaders,
17
+ GatewayError,
18
+ holdLimitRefusal,
19
+ loginHeaders,
20
+ parseJson,
21
+ pipeResponse,
22
+ readBody,
23
+ sendError,
24
+ upstreamMessage,
25
+ watchUpstream,
26
+ } from './forward.js';
14
27
  import { chainOf, forgetFallbackState, noteChoice, noteRefused, routesToTry } from './fallback.js';
15
28
  import {
16
29
  BINDING_BETA,
@@ -38,6 +51,7 @@ import { forgetUsage, noteUsageHeaders, UsageScanner } from './usage.js';
38
51
  import { fitToolSearch } from './tool-search.js';
39
52
  import { ANTHROPIC_API, refreshLoginModels } from './provider-models.js';
40
53
  import type { CodexTokens } from './codex-auth.js';
54
+ import { forgetClaudeTokens, withClaudeLogin, type ClaudeLoginDeps } from './claude-logins.js';
41
55
 
42
56
  const GATEWAY_PREFIX = '/gateway';
43
57
  const MESSAGES = '/v1/messages';
@@ -53,6 +67,7 @@ export interface GatewayDeps {
53
67
  openrouterBase?: string;
54
68
  codex?: Partial<CodexDeps>;
55
69
  gemini?: Partial<GeminiDeps>;
70
+ claudeLogins?: ClaudeLoginDeps;
56
71
  }
57
72
 
58
73
  const learned: Adaptations = freshAdaptations();
@@ -66,6 +81,7 @@ export function resetGatewayState(): void {
66
81
  forgetLearnedThinking();
67
82
  sharedCodexState.clear();
68
83
  sharedGeminiState.clear();
84
+ forgetClaudeTokens();
69
85
  }
70
86
 
71
87
  const saveCodexTokens = (id: string, tokens: CodexTokens): void => {
@@ -90,7 +106,18 @@ function modelsBody(cfg: ModelConfig): Record<string, unknown> {
90
106
  return { data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null };
91
107
  }
92
108
 
93
- const PASSTHROUGH: Connection = { id: PASSTHROUGH_ID, provider: 'anthropic', label: 'Claude Code login', model: '', apiKey: '', region: '', zdr: false, codex: null, gemini: null };
109
+ const PASSTHROUGH: Connection = {
110
+ id: PASSTHROUGH_ID,
111
+ provider: 'anthropic',
112
+ label: 'Claude Code login',
113
+ model: '',
114
+ apiKey: '',
115
+ region: '',
116
+ zdr: false,
117
+ codex: null,
118
+ gemini: null,
119
+ claude: null,
120
+ };
94
121
 
95
122
  const passthrough = (body: Record<string, unknown>): Route => ({ connection: PASSTHROUGH, model: requestedModel(body) });
96
123
 
@@ -139,12 +166,27 @@ async function refusedThinkingOff(upstream: Response, shaped: Record<string, unk
139
166
  return true;
140
167
  }
141
168
 
142
- function listLoginModels(req: IncomingMessage, anthropicBase: string): void {
143
- refreshLoginModels(req.headers, anthropicBase).catch((err: unknown) => {
169
+ function listLoginModels(headers: IncomingHttpHeaders, anthropicBase: string): void {
170
+ refreshLoginModels(headers, anthropicBase).catch((err: unknown) => {
144
171
  log.warn({ err: errMsg(err) }, 'gateway: could not list the models of the Claude Code login');
145
172
  });
146
173
  }
147
174
 
175
+ function sendAnthropic(req: IncomingMessage, conn: Connection, anthropicBase: string, attempt: (base: Record<string, string>) => Promise<Response>, deps: GatewayDeps): Promise<Response> {
176
+ if (conn.apiKey !== '') return attempt(anthropicHeaders(req, conn.apiKey));
177
+ if (conn.claude === null) return attempt(forwardedHeaders(req));
178
+ return withClaudeLogin(conn.claude.id, deps.claudeLogins ?? {}, (token) => {
179
+ listLoginModels({ authorization: `Bearer ${token}` }, anthropicBase);
180
+ return attempt(loginHeaders(req, token));
181
+ });
182
+ }
183
+
184
+ async function refusedLogin(conn: Connection, upstream: Response): Promise<void> {
185
+ if (conn.apiKey !== '' || conn.claude === null || upstream.status !== 401) return;
186
+ await upstream.body?.cancel();
187
+ throw new GatewayError(403, 'permission_error', `Anthropic refused the Claude login of ${conn.label}: sign in again on the Model page`);
188
+ }
189
+
148
190
  async function toAnthropic(
149
191
  req: IncomingMessage,
150
192
  res: ServerResponse,
@@ -157,25 +199,28 @@ async function toAnthropic(
157
199
  const conn = route.connection;
158
200
  const anthropicBase = deps.anthropicBase ?? ANTHROPIC_API;
159
201
  const url = `${anthropicBase}${(req.url ?? '').slice(GATEWAY_PREFIX.length)}`;
160
- const key = conn.apiKey;
161
- if (key === '' && standsInFor(req))
202
+ const passthrough = conn.apiKey === '' && conn.claude === null;
203
+ if (passthrough && standsInFor(req))
162
204
  throw new GatewayError(
163
205
  403,
164
206
  'permission_error',
165
207
  'Claude Code on this machine has no Anthropic login of its own; choose Bedrock, OpenRouter, Codex or Gemini on the Model page, or sign in on the Claude tab',
166
208
  );
167
209
  const watch = watchUpstream(res);
168
- if (key === '') listLoginModels(req, anthropicBase);
169
- const base = key === '' ? forwardedHeaders(req) : anthropicHeaders(req, key);
210
+ if (passthrough) listLoginModels(req.headers, anthropicBase);
170
211
  const send = (payload: Buffer, headers: Record<string, string>): Promise<Response> =>
171
212
  fetch(url, { method: 'POST', headers, body: new Uint8Array(payload), signal: watch.signal, redirect: 'manual' });
172
- const attempt = (): Promise<Response> => asAnthropicWants(send, base, anthropicPayloads(raw, sent, shaped, route.model), conn.label, route.model);
173
- const first = await attempt();
174
- const upstream = (await refusedThinkingOff(first, shaped, route.model)) ? await attempt() : first;
213
+ const attempt = async (base: Record<string, string>): Promise<Response> => {
214
+ const shape = (): Promise<Response> => asAnthropicWants(send, base, anthropicPayloads(raw, sent, shaped, route.model), conn.label, route.model);
215
+ const first = await shape();
216
+ return (await refusedThinkingOff(first, shaped, route.model)) ? shape() : first;
217
+ };
218
+ const upstream = await sendAnthropic(req, conn, anthropicBase, attempt, deps);
219
+ await refusedLogin(conn, upstream);
175
220
  noteRefusal(conn.label, route.model, upstream);
176
221
  noteUsageHeaders('anthropic', conn.id, upstream.headers);
177
222
  const scanner = new UsageScanner(conn.id);
178
- await pipeResponse(upstream, res, watch, key === '' ? { scanner } : { ownCredential: true, scanner });
223
+ await pipeResponse(upstream, res, watch, passthrough ? { scanner } : { ownCredential: true, scanner });
179
224
  }
180
225
 
181
226
  async function toOpenRouter(
@@ -20,7 +20,8 @@ import {
20
20
  writeModelConfig,
21
21
  type ModelConfig,
22
22
  } from './model-config.js';
23
- import { asApiError, BODY_MAX, connectionFor, settingsBody, type ModelApiDeps, type Route, type Store } from './model-store.js';
23
+ import { asApiError, BODY_MAX, claudeLoginDeps, connectionFor, settingsBody, type ModelApiDeps, type Route, type Store } from './model-store.js';
24
+ import { dropUnusedClaudeLogins } from './claude-logins.js';
24
25
  import { CODEX_ROUTES, codexDeviceRoute, GEMINI_ROUTES } from './model-signin.js';
25
26
  import { refreshUsage } from './usage-refresh.js';
26
27
 
@@ -89,14 +90,17 @@ async function change(req: IncomingMessage, deps: ModelApiDeps, store: Store, id
89
90
  }
90
91
 
91
92
  function drop(deps: ModelApiDeps, store: Store, id: string): unknown {
93
+ const before = store.read();
92
94
  let next: ModelConfig;
93
95
  try {
94
- next = removeConnection(store.read(), id);
96
+ next = removeConnection(before, id);
95
97
  } catch (err) {
96
98
  asApiError(err);
97
99
  }
98
100
  forgetOne(id);
99
- return kept(store, next, deps, 'model-api: connection removed', { connection: id });
101
+ const body = kept(store, next, deps, 'model-api: connection removed', { connection: id });
102
+ dropUnusedClaudeLogins(before, next, claudeLoginDeps(deps));
103
+ return body;
100
104
  }
101
105
 
102
106
  async function settingsWithUsage(deps: ModelApiDeps, store: Store): Promise<Record<string, unknown>> {
@@ -132,7 +136,11 @@ const named = (table: Record<string, Route>, name: string, method: string | unde
132
136
  async function restore(req: IncomingMessage, store: Store, deps: ModelApiDeps): Promise<unknown> {
133
137
  const body = await readJsonBody(req, RESTORE_MAX);
134
138
  if (!isRecord(body)) throw new ApiError('body must be a JSON object', 400);
135
- return kept(store, parseModelConfig(body), deps, 'model-api: model setup restored from a file', {});
139
+ const before = store.read();
140
+ const next = parseModelConfig(body);
141
+ const answer = kept(store, next, deps, 'model-api: model setup restored from a file', {});
142
+ dropUnusedClaudeLogins(before, next, claudeLoginDeps(deps));
143
+ return answer;
136
144
  }
137
145
 
138
146
  function bundleRoute(path: string, method: string | undefined): Route | number {
@@ -1,5 +1,5 @@
1
1
  import { join } from 'node:path';
2
- import { newId } from '@metro-labs/core/ids';
2
+ import { ID_RE, newId } from '@metro-labs/core/ids';
3
3
  import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
4
4
  import { agentsDir } from '../agents/files.js';
5
5
  import { isRecord } from '@metro-labs/core/is-record';
@@ -9,6 +9,13 @@ import { tokensFromDisk as geminiTokensFromDisk, type GeminiTokens } from './gem
9
9
  export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex', 'gemini'] as const;
10
10
  export type Provider = (typeof PROVIDERS)[number];
11
11
 
12
+ export interface ClaudeLogin {
13
+ id: string;
14
+ email: string | null;
15
+ plan: string | null;
16
+ savedAt: string;
17
+ }
18
+
12
19
  export interface Connection {
13
20
  id: string;
14
21
  provider: Provider;
@@ -19,6 +26,7 @@ export interface Connection {
19
26
  zdr: boolean;
20
27
  codex: CodexTokens | null;
21
28
  gemini: GeminiTokens | null;
29
+ claude: ClaudeLogin | null;
22
30
  }
23
31
 
24
32
  export interface Fallback {
@@ -73,8 +81,14 @@ const newConnection = (provider: Provider, label: string): Connection => ({
73
81
  zdr: false,
74
82
  codex: null,
75
83
  gemini: null,
84
+ claude: null,
76
85
  });
77
86
 
87
+ function claudeFromDisk(raw: unknown): ClaudeLogin | null {
88
+ if (!isRecord(raw) || !ID_RE.test(text(raw.id))) return null;
89
+ return { id: text(raw.id), email: maybe(raw.email), plan: maybe(raw.plan), savedAt: text(raw.savedAt) };
90
+ }
91
+
78
92
  function codexFromDisk(raw: unknown): CodexTokens | null {
79
93
  if (!isRecord(raw)) return null;
80
94
  const accessToken = text(raw.accessToken);
@@ -106,6 +120,7 @@ function connectionFromDisk(raw: unknown): Connection | null {
106
120
  zdr: raw.zdr === true,
107
121
  codex: codexFromDisk(raw.codex),
108
122
  gemini: geminiTokensFromDisk(raw.gemini),
123
+ claude: provider === 'anthropic' ? claudeFromDisk(raw.claude) : null,
109
124
  };
110
125
  }
111
126
 
@@ -219,6 +234,7 @@ export const routeLabel = (route: Route): string =>
219
234
  export const PASSTHROUGH_ID = 'passthrough';
220
235
 
221
236
  function signedInAs(c: Connection): { account: string | null; plan: string | null } {
237
+ if (c.claude !== null) return { account: c.claude.email, plan: c.claude.plan };
222
238
  if (c.codex === null) return { account: c.gemini?.email ?? null, plan: c.gemini?.tier ?? null };
223
239
  const claims = claimsOf(c.codex.idToken);
224
240
  return { account: c.codex.email ?? claims.email, plan: c.codex.plan ?? claims.plan };
@@ -233,7 +249,7 @@ function publicConnection(c: Connection): Record<string, unknown> {
233
249
  hasKey: c.apiKey !== '',
234
250
  region: c.region,
235
251
  zdr: c.zdr,
236
- signedIn: c.codex !== null || c.gemini !== null,
252
+ signedIn: c.codex !== null || c.gemini !== null || c.claude !== null,
237
253
  ...signedInAs(c),
238
254
  };
239
255
  }
@@ -254,6 +270,9 @@ export const setCodexAuth = (cfg: ModelConfig, id: string, auth: CodexTokens | n
254
270
  export const setGeminiAuth = (cfg: ModelConfig, id: string, auth: GeminiTokens | null): ModelConfig =>
255
271
  withConnection(cfg, id, (c) => ({ ...c, gemini: auth }));
256
272
 
273
+ export const setClaudeLogin = (cfg: ModelConfig, id: string, login: ClaudeLogin | null): ModelConfig =>
274
+ withConnection(cfg, id, (c) => ({ ...c, claude: login }));
275
+
257
276
  function field(patch: Record<string, unknown>, key: string, current: string, what: string, max = MAX_FIELD): string {
258
277
  if (!(key in patch)) return current;
259
278
  const value = patch[key];
@@ -42,6 +42,8 @@ const saved = (store: Store, cfg: ModelConfig, note: string, fields: { connectio
42
42
 
43
43
  function keepCodex(store: Store, req: IncomingMessage, tokens: CodexTokens | null, note: string): unknown {
44
44
  const { cfg, id } = connectionToFill(store, req, 'codex');
45
+ const twin = cfg.connections.find((c) => c.id !== id && tokens !== null && c.codex?.refreshToken === tokens.refreshToken);
46
+ if (twin !== undefined) throw new ApiError(`this ChatGPT login is already connected as ${twin.label}; sign in with ChatGPT to add another one`, 409);
45
47
  const next = setCodexAuth(cfg, id, tokens);
46
48
  return saved(store, next, note, { connection: id, plan: tokens?.plan ?? null });
47
49
  }
@@ -8,6 +8,7 @@ import { usageSeen } from './usage.js';
8
8
  import { chainStatus } from './fallback.js';
9
9
  import { ModelConfigError, connectionOf, publicModelConfig, routedConnection, type Connection, type ModelConfig, type Provider } from './model-config.js';
10
10
  import type { SetupDeps } from '../claude/setup.js';
11
+ import type { ClaudeLoginDeps } from './claude-logins.js';
11
12
 
12
13
  export interface ModelApiDeps {
13
14
  read?: () => ModelConfig;
@@ -25,10 +26,13 @@ export interface ModelApiDeps {
25
26
  bedrockControlBase?: string;
26
27
  setup?: SetupDeps;
27
28
  restartSession?: () => boolean;
28
- claudeUsage?: () => Promise<unknown>;
29
+ claudeUsage?: (env?: Record<string, string>) => Promise<unknown>;
30
+ claudeLoginsRoot?: string;
29
31
  usageWaitMs?: number;
30
32
  }
31
33
 
34
+ export const claudeLoginDeps = (deps: ModelApiDeps): ClaudeLoginDeps => ({ root: deps.claudeLoginsRoot, probe: deps.claudeUsage });
35
+
32
36
  export interface Store {
33
37
  read: () => ModelConfig;
34
38
  write: (cfg: ModelConfig) => void;
@@ -1,6 +1,6 @@
1
1
  import type { IncomingHttpHeaders } from 'node:http';
2
2
  import { isRecord } from '@metro-labs/core/is-record';
3
- import { GatewayError } from './forward.js';
3
+ import { GatewayError, OAUTH_BETA } from './forward.js';
4
4
  import type { Connection } from './model-config.js';
5
5
  import { fingerprint, listCache } from './model-lists.js';
6
6
  import { stringOf } from '@metro-labs/http/api-http';
@@ -9,7 +9,6 @@ export const ANTHROPIC_API = 'https://api.anthropic.com';
9
9
  const ANTHROPIC_VERSION = '2023-06-01';
10
10
  const LIST_MAX = 1000;
11
11
  const LIST_TIMEOUT_MS = 10_000;
12
- const OAUTH_BETA = 'oauth-2025-04-20';
13
12
  const BEARER = 'Bearer ';
14
13
 
15
14
  export interface ProviderModel {
@@ -6,7 +6,8 @@ import { lastServed } from './served.js';
6
6
  import { claudeLoginUsage, geminiUsage, keepProbeAnswer, mayProbe, mergeUsage, noteUsage, openrouterUsage, probeAnswer, usageOf } from './usage.js';
7
7
  import { PASSTHROUGH_ID, readModelConfig, routedConnection, writeModelConfig, type Connection, type ModelConfig } from './model-config.js';
8
8
  import { codexDepsFor, geminiModelsOf } from './model-signin.js';
9
- import { CREDITS_TTL_MS, type ModelApiDeps, type Store } from './model-store.js';
9
+ import { claudeLoginDeps, CREDITS_TTL_MS, type ModelApiDeps, type Store } from './model-store.js';
10
+ import { probeClaudeLogin } from './claude-logins.js';
10
11
 
11
12
  const WAIT_MS = 4_000;
12
13
  const LOGIN_PROBE = 'claude-login';
@@ -62,17 +63,26 @@ async function refreshQuota(conn: Connection, deps: ModelApiDeps, store: Store,
62
63
 
63
64
  const loginIds = (cfg: ModelConfig): string[] => [
64
65
  ...(routedConnection(cfg) === null ? [PASSTHROUGH_ID] : []),
65
- ...cfg.connections.filter((c) => c.provider === 'anthropic' && c.apiKey === '').map((c) => c.id),
66
+ ...cfg.connections.filter((c) => c.provider === 'anthropic' && c.apiKey === '' && c.claude === null).map((c) => c.id),
66
67
  ];
67
68
 
68
69
  const hasFallbacks = (cfg: ModelConfig): boolean => (cfg.fallbacks ?? []).length > 0;
69
70
 
71
+ async function refreshOwnLogin(conn: Connection, cfg: ModelConfig, deps: ModelApiDeps, now: number): Promise<void> {
72
+ const login = conn.apiKey === '' ? conn.claude : null;
73
+ if (login === null || (fresh(conn.id, now) && !hasFallbacks(cfg)) || !mayProbe(conn.id, now, CREDITS_TTL_MS)) return;
74
+ await probe('usage of a Claude login', async () => {
75
+ const usage = claudeLoginUsage(await probeClaudeLogin(login.id, claudeLoginDeps(deps)), new Date(now));
76
+ if (usage !== null) mergeUsage(conn.id, usage);
77
+ });
78
+ }
79
+
70
80
  async function refreshLogin(cfg: ModelConfig, deps: ModelApiDeps, now: number): Promise<void> {
71
81
  const ids = loginIds(cfg);
72
82
  if (!ids.some((id) => !fresh(id, now)) && !(hasFallbacks(cfg) && ids.length > 0)) return;
73
83
  if (mayProbe(LOGIN_PROBE, now, CREDITS_TTL_MS))
74
84
  await probe('usage of the Claude Code login', async () => {
75
- const usage = claudeLoginUsage(await (deps.claudeUsage ?? readClaudeUsage)(), new Date(now));
85
+ const usage = claudeLoginUsage(await (deps.claudeUsage ?? (() => readClaudeUsage()))(), new Date(now));
76
86
  if (usage !== null) keepProbeAnswer(LOGIN_PROBE, usage);
77
87
  });
78
88
  const usage = probeAnswer(LOGIN_PROBE);
@@ -80,10 +90,11 @@ async function refreshLogin(cfg: ModelConfig, deps: ModelApiDeps, now: number):
80
90
  for (const id of ids) mergeUsage(id, usage);
81
91
  }
82
92
 
83
- function refreshOne(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
93
+ function refreshOne(conn: Connection, cfg: ModelConfig, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
84
94
  if (conn.provider === 'openrouter') return refreshKey(conn, deps, now);
85
95
  if (conn.provider === 'codex') return refreshCodex(conn, deps, store, now);
86
96
  if (conn.provider === 'gemini') return refreshQuota(conn, deps, store, now);
97
+ if (conn.provider === 'anthropic') return refreshOwnLogin(conn, cfg, deps, now);
87
98
  return Promise.resolve();
88
99
  }
89
100
 
@@ -98,7 +109,7 @@ async function within(ms: number, work: Promise<unknown>): Promise<void> {
98
109
 
99
110
  export async function refreshUsage(deps: ModelApiDeps, store: Store, now = Date.now()): Promise<void> {
100
111
  const cfg = store.read();
101
- const work = Promise.all([...cfg.connections.map((conn) => refreshOne(conn, deps, store, now)), refreshLogin(cfg, deps, now)]);
112
+ const work = Promise.all([...cfg.connections.map((conn) => refreshOne(conn, cfg, deps, store, now)), refreshLogin(cfg, deps, now)]);
102
113
  await within(deps.usageWaitMs ?? WAIT_MS, work);
103
114
  }
104
115
 
@@ -17,7 +17,7 @@
17
17
  "discord.js": "^14.26.4"
18
18
  },
19
19
  "devDependencies": {
20
- "@stage-labs/config": "0.1.0-beta.2",
20
+ "@stage-labs/config": "0.1.0-beta.3",
21
21
  "eslint": "^10.3.0",
22
22
  "knip": "^6.15.0",
23
23
  "madge": "^8.0.0",
@@ -16,7 +16,7 @@
16
16
  "@metro-labs/core": "workspace:*"
17
17
  },
18
18
  "devDependencies": {
19
- "@stage-labs/config": "0.1.0-beta.2",
19
+ "@stage-labs/config": "0.1.0-beta.3",
20
20
  "eslint": "^10.3.0",
21
21
  "knip": "^6.15.0",
22
22
  "madge": "^8.0.0",
@@ -21,7 +21,7 @@
21
21
  "@metro-labs/core": "workspace:*"
22
22
  },
23
23
  "devDependencies": {
24
- "@stage-labs/config": "0.1.0-beta.2",
24
+ "@stage-labs/config": "0.1.0-beta.3",
25
25
  "eslint": "^10.3.0",
26
26
  "knip": "^6.15.0",
27
27
  "madge": "^8.0.0",
@@ -6,6 +6,10 @@ export function allowedWebHost(host: string): boolean {
6
6
  return PREVIEW.test(host);
7
7
  }
8
8
 
9
+ export const APP_RETURN = 'metro://auth';
10
+
11
+ export const isAppReturn = (returnTo: string): boolean => returnTo === APP_RETURN;
12
+
9
13
  export function validateReturnTo(returnTo: string): boolean {
10
14
  let url: URL;
11
15
  try {
@@ -16,7 +16,7 @@
16
16
  "@metro-labs/core": "workspace:*"
17
17
  },
18
18
  "devDependencies": {
19
- "@stage-labs/config": "0.1.0-beta.2",
19
+ "@stage-labs/config": "0.1.0-beta.3",
20
20
  "eslint": "^10.3.0",
21
21
  "knip": "^6.15.0",
22
22
  "madge": "^8.0.0",
@@ -17,7 +17,7 @@
17
17
  "@mtcute/bun": "0.30.1"
18
18
  },
19
19
  "devDependencies": {
20
- "@stage-labs/config": "0.1.0-beta.2",
20
+ "@stage-labs/config": "0.1.0-beta.3",
21
21
  "eslint": "^10.3.0",
22
22
  "knip": "^6.15.0",
23
23
  "madge": "^8.0.0",
@@ -16,7 +16,7 @@
16
16
  "@metro-labs/core": "workspace:*"
17
17
  },
18
18
  "devDependencies": {
19
- "@stage-labs/config": "0.1.0-beta.2",
19
+ "@stage-labs/config": "0.1.0-beta.3",
20
20
  "eslint": "^10.3.0",
21
21
  "knip": "^6.15.0",
22
22
  "madge": "^8.0.0",
@@ -17,7 +17,7 @@
17
17
  "tweetnacl": "^1.0.3"
18
18
  },
19
19
  "devDependencies": {
20
- "@stage-labs/config": "0.1.0-beta.2",
20
+ "@stage-labs/config": "0.1.0-beta.3",
21
21
  "eslint": "^10.3.0",
22
22
  "knip": "^6.15.0",
23
23
  "madge": "^8.0.0",
@@ -14,7 +14,7 @@
14
14
  "@metro-labs/core": "workspace:*"
15
15
  },
16
16
  "devDependencies": {
17
- "@stage-labs/config": "0.1.0-beta.2",
17
+ "@stage-labs/config": "0.1.0-beta.3",
18
18
  "eslint": "^10.3.0",
19
19
  "knip": "^6.15.0",
20
20
  "madge": "^8.0.0",
@@ -17,7 +17,7 @@
17
17
  "baileys": "7.0.0-rc14"
18
18
  },
19
19
  "devDependencies": {
20
- "@stage-labs/config": "0.1.0-beta.2",
20
+ "@stage-labs/config": "0.1.0-beta.3",
21
21
  "eslint": "^10.3.0",
22
22
  "knip": "^6.15.0",
23
23
  "madge": "^8.0.0",
@@ -25,7 +25,7 @@
25
25
  "viem": "^2.52.2"
26
26
  },
27
27
  "devDependencies": {
28
- "@stage-labs/config": "0.1.0-beta.2",
28
+ "@stage-labs/config": "0.1.0-beta.3",
29
29
  "eslint": "^10.3.0",
30
30
  "knip": "^6.15.0",
31
31
  "madge": "^8.0.0",
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.252"
2
+ "version": "0.1.0-beta.254"
3
3
  }