@mnemom/mnemom 0.7.2 → 0.9.0

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/lib/auth.js CHANGED
@@ -1,24 +1,122 @@
1
+ /**
2
+ * Auth credential management.
3
+ *
4
+ * Stores auth tokens in ~/.mnemom/auth.json (UC-9: no more config.json).
5
+ * License JWTs are stored alongside auth tokens.
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
1
9
  import * as http from "node:http";
2
10
  import * as crypto from "node:crypto";
3
11
  import { exec } from "node:child_process";
4
- import { getApiUrl, getWebsiteUrl, getAuthInfo, saveAuthTokens, loadConfig } from "./config.js";
12
+ import { getApiUrl, getWebsiteUrl, MNEMOM_DIR } from "./config.js";
13
+ // ============================================================================
14
+ // Auth Store (persisted to ~/.mnemom/auth.json)
15
+ // ============================================================================
16
+ const AUTH_FILE = path.join(MNEMOM_DIR, "auth.json");
17
+ function loadAuthStore() {
18
+ try {
19
+ if (!fs.existsSync(AUTH_FILE))
20
+ return null;
21
+ const content = fs.readFileSync(AUTH_FILE, "utf-8");
22
+ return JSON.parse(content);
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ function saveAuthStore(store) {
29
+ if (!fs.existsSync(MNEMOM_DIR)) {
30
+ fs.mkdirSync(MNEMOM_DIR, { recursive: true });
31
+ }
32
+ const resolvedPath = path.resolve(AUTH_FILE);
33
+ const sanitized = JSON.parse(JSON.stringify(store));
34
+ const tmpFile = `${resolvedPath}.${process.pid}.tmp`;
35
+ fs.writeFileSync(tmpFile, JSON.stringify(sanitized, null, 2));
36
+ fs.renameSync(tmpFile, resolvedPath);
37
+ }
38
+ // ============================================================================
39
+ // Auth token helpers
40
+ // ============================================================================
41
+ export function saveAuthTokens(tokens) {
42
+ const store = loadAuthStore() ?? {};
43
+ store.auth = tokens;
44
+ saveAuthStore(store);
45
+ }
46
+ export function clearAuthTokens() {
47
+ const store = loadAuthStore();
48
+ if (!store)
49
+ return;
50
+ delete store.auth;
51
+ saveAuthStore(store);
52
+ }
53
+ export function getAuthInfo() {
54
+ return loadAuthStore()?.auth ?? null;
55
+ }
56
+ // ============================================================================
57
+ // License JWT helpers
58
+ // ============================================================================
59
+ export function saveLicenseJwt(jwt) {
60
+ const store = loadAuthStore() ?? {};
61
+ store.licenseJwt = jwt;
62
+ saveAuthStore(store);
63
+ }
64
+ export function clearLicenseJwt() {
65
+ const store = loadAuthStore();
66
+ if (!store)
67
+ return;
68
+ delete store.licenseJwt;
69
+ saveAuthStore(store);
70
+ }
71
+ export function getLicenseJwt() {
72
+ return loadAuthStore()?.licenseJwt ?? null;
73
+ }
5
74
  /** Sanitize file-sourced data before use in outbound HTTP requests. */
6
75
  function sanitizeForHttp(data) {
7
76
  return String(data).trim();
8
77
  }
78
+ /**
79
+ * Decode a JWT's `exp` claim (unix seconds), or null if the token can't be
80
+ * parsed. We use the JWT's own exp as the source of truth for expiry rather
81
+ * than `expires_in` returned by the auth endpoint — the two can disagree
82
+ * (Supabase has been observed reporting expires_in values longer than the
83
+ * JWT's actual exp), and a divergence makes `whoami` cheerfully report a
84
+ * "valid" token while every authenticated API call gets 401.
85
+ */
86
+ function jwtExpSeconds(accessToken) {
87
+ const parts = accessToken.split(".");
88
+ if (parts.length !== 3)
89
+ return null;
90
+ try {
91
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
92
+ if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) {
93
+ return null;
94
+ }
95
+ return payload.exp;
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ /**
102
+ * Compute the effective expiresAt for a freshly issued access token.
103
+ * Prefers the JWT's own `exp` claim; falls back to `now + expires_in` if the
104
+ * token can't be parsed (e.g. an opaque token).
105
+ */
106
+ export function computeExpiresAt(accessToken, expiresInSeconds) {
107
+ return jwtExpSeconds(accessToken) ?? Math.floor(Date.now() / 1000) + expiresInSeconds;
108
+ }
9
109
  /**
10
110
  * Get a valid access token, or null if not authenticated.
11
111
  *
12
112
  * Resolution order:
13
- * 1. SMOLTBOT_TOKEN environment variable (CI / non-interactive)
14
- * 2. Stored token from config (auto-refreshes if expired)
113
+ * 1. MNEMOM_TOKEN environment variable (CI / non-interactive)
114
+ * 2. Stored token from auth store (auto-refreshes if expired)
15
115
  */
16
116
  export async function getAccessToken() {
17
- // 1. Env var override (CI / non-interactive)
18
- const envToken = process.env.SMOLTBOT_TOKEN;
117
+ const envToken = process.env.MNEMOM_TOKEN;
19
118
  if (envToken)
20
119
  return envToken;
21
- // 2. Stored token
22
120
  const auth = getAuthInfo();
23
121
  if (!auth)
24
122
  return null;
@@ -27,43 +125,54 @@ export async function getAccessToken() {
27
125
  if (auth.expiresAt > now + 60) {
28
126
  return auth.accessToken;
29
127
  }
30
- // 3. Auto-refresh
128
+ // Auto-refresh
31
129
  const refreshed = await refreshAccessToken(auth.refreshToken);
32
130
  if (refreshed)
33
131
  return refreshed.accessToken;
34
132
  return null;
35
133
  }
134
+ /**
135
+ * Force a refresh of the stored access token regardless of local expiry, and
136
+ * return the new access token (or null if refresh failed).
137
+ *
138
+ * Intended as a 401-recovery hook for callers: if an authenticated request
139
+ * comes back unauthorized despite the local cache claiming a valid token, the
140
+ * cache is stale (clock skew, divergence between expires_in and the JWT's
141
+ * actual exp, or server-side revocation). Force a refresh and retry once.
142
+ */
143
+ export async function forceRefreshAccessToken() {
144
+ const envToken = process.env.MNEMOM_TOKEN;
145
+ if (envToken)
146
+ return envToken; // env-supplied tokens are not refreshable
147
+ const auth = getAuthInfo();
148
+ if (!auth?.refreshToken)
149
+ return null;
150
+ const refreshed = await refreshAccessToken(auth.refreshToken);
151
+ return refreshed?.accessToken ?? null;
152
+ }
36
153
  /**
37
154
  * Get a valid access token or exit with a helpful message.
38
155
  */
39
156
  export async function requireAccessToken() {
40
157
  const token = await getAccessToken();
41
158
  if (!token) {
42
- console.error("Authentication required. Run `smoltbot login` first.");
159
+ console.error("Authentication required. Run `mnemom login` first.");
43
160
  process.exit(1);
44
161
  }
45
162
  return token;
46
163
  }
47
164
  /**
48
- * Get the Mnemom API key from env var or config.
49
- *
50
- * Resolution order:
51
- * 1. MNEMOM_API_KEY environment variable
52
- * 2. Stored mnemomApiKey from config
165
+ * Get the Mnemom API key from env var.
53
166
  */
54
167
  export function getMnemomApiKey() {
55
- const envKey = process.env.MNEMOM_API_KEY;
56
- if (envKey)
57
- return envKey;
58
- const config = loadConfig();
59
- return config?.mnemomApiKey ?? null;
168
+ return process.env.MNEMOM_API_KEY ?? null;
60
169
  }
61
170
  /**
62
171
  * Resolve the best available auth credential.
63
172
  *
64
173
  * Resolution order:
65
- * 1. JWT (SMOLTBOT_TOKEN env or stored token with auto-refresh)
66
- * 2. API key (MNEMOM_API_KEY env or config mnemomApiKey)
174
+ * 1. JWT (MNEMOM_TOKEN env or stored token with auto-refresh)
175
+ * 2. API key (MNEMOM_API_KEY env)
67
176
  * 3. None
68
177
  */
69
178
  export async function resolveAuth() {
@@ -81,20 +190,21 @@ export async function resolveAuth() {
81
190
  export async function requireAuth() {
82
191
  const cred = await resolveAuth();
83
192
  if (cred.type === "none") {
84
- console.error("Authentication required. Run `smoltbot login` or set MNEMOM_API_KEY.");
193
+ console.error("Authentication required. Run `mnemom login` or set MNEMOM_API_KEY.");
85
194
  process.exit(1);
86
195
  }
87
196
  return cred;
88
197
  }
89
198
  /**
90
- * Authenticate via browser-based login flow.
91
- *
92
- * 1. Start a local HTTP server on a random port
93
- * 2. Generate a random `state` nonce for CSRF protection
94
- * 3. Open the browser to the API's CLI login page
95
- * 4. Wait for the login page to POST tokens back to localhost
96
- * 5. Verify state, store tokens, and close the server
199
+ * Check if the user is logged in (has any credential).
97
200
  */
201
+ export async function isLoggedIn() {
202
+ const cred = await resolveAuth();
203
+ return cred.type !== "none";
204
+ }
205
+ // ============================================================================
206
+ // Browser login flow
207
+ // ============================================================================
98
208
  export async function loginWithBrowser() {
99
209
  const state = crypto.randomBytes(16).toString("hex");
100
210
  const { port, tokenPromise, close } = await startCallbackServer(state);
@@ -113,10 +223,6 @@ export async function loginWithBrowser() {
113
223
  close();
114
224
  }
115
225
  }
116
- /**
117
- * Start a local HTTP server that listens for the auth callback POST.
118
- * Returns the assigned port, a promise that resolves with tokens, and a close function.
119
- */
120
226
  async function startCallbackServer(expectedState) {
121
227
  let resolveTokens;
122
228
  let rejectTokens;
@@ -125,7 +231,6 @@ async function startCallbackServer(expectedState) {
125
231
  rejectTokens = reject;
126
232
  });
127
233
  const server = http.createServer((req, res) => {
128
- // Handle CORS preflight for the POST from the browser page
129
234
  if (req.method === "OPTIONS") {
130
235
  res.writeHead(200, {
131
236
  "Access-Control-Allow-Origin": "*",
@@ -143,7 +248,6 @@ async function startCallbackServer(expectedState) {
143
248
  let body = "";
144
249
  req.on("data", (chunk) => {
145
250
  body += chunk.toString();
146
- // Limit body size to prevent abuse
147
251
  if (body.length > 1_000_000) {
148
252
  req.destroy();
149
253
  rejectTokens(new Error("Callback body too large"));
@@ -157,14 +261,14 @@ async function startCallbackServer(expectedState) {
157
261
  "Content-Type": "text/html",
158
262
  "Access-Control-Allow-Origin": "*",
159
263
  });
160
- res.end("<html><body><h2>Authentication failed</h2><p>State mismatch. Please try again.</p></body></html>");
264
+ res.end("<html><body><h2>Authentication failed</h2><p>State mismatch.</p></body></html>");
161
265
  rejectTokens(new Error("State mismatch — possible CSRF attack"));
162
266
  return;
163
267
  }
164
268
  const tokens = {
165
269
  accessToken: data.access_token,
166
270
  refreshToken: data.refresh_token,
167
- expiresAt: Math.floor(Date.now() / 1000) + data.expires_in,
271
+ expiresAt: computeExpiresAt(data.access_token, data.expires_in),
168
272
  userId: data.user_id,
169
273
  email: data.user_email,
170
274
  };
@@ -188,13 +292,11 @@ async function startCallbackServer(expectedState) {
188
292
  }
189
293
  });
190
294
  });
191
- // Listen on port 0, wait for the server to be ready before reading the address
192
295
  const port = await new Promise((resolve) => {
193
296
  server.listen(0, "127.0.0.1", () => {
194
297
  resolve(server.address().port);
195
298
  });
196
299
  });
197
- // Auto-timeout after 5 minutes
198
300
  const timeout = setTimeout(() => {
199
301
  rejectTokens(new Error("Login timed out. Please try again."));
200
302
  server.close();
@@ -208,9 +310,6 @@ async function startCallbackServer(expectedState) {
208
310
  },
209
311
  };
210
312
  }
211
- /**
212
- * Open a URL in the user's default browser.
213
- */
214
313
  function openBrowser(url) {
215
314
  const cmd = process.platform === "darwin"
216
315
  ? "open"
@@ -219,10 +318,9 @@ function openBrowser(url) {
219
318
  : "xdg-open";
220
319
  exec(`${cmd} ${JSON.stringify(url)}`);
221
320
  }
222
- /**
223
- * Authenticate with email + password via the API auth proxy.
224
- * Used by --no-browser fallback.
225
- */
321
+ // ============================================================================
322
+ // Password login
323
+ // ============================================================================
226
324
  export async function loginWithPassword(email, password) {
227
325
  const url = `${getApiUrl()}/v1/auth/login`;
228
326
  const res = await fetch(url, {
@@ -238,17 +336,16 @@ export async function loginWithPassword(email, password) {
238
336
  const tokens = {
239
337
  accessToken: data.access_token,
240
338
  refreshToken: data.refresh_token,
241
- expiresAt: Math.floor(Date.now() / 1000) + data.expires_in,
339
+ expiresAt: computeExpiresAt(data.access_token, data.expires_in),
242
340
  userId: data.user.id,
243
341
  email: data.user.email,
244
342
  };
245
343
  saveAuthTokens(tokens);
246
344
  return tokens;
247
345
  }
248
- /**
249
- * Refresh an expired access token.
250
- * Returns new tokens on success, null on failure.
251
- */
346
+ // ============================================================================
347
+ // Token refresh
348
+ // ============================================================================
252
349
  async function refreshAccessToken(refreshToken) {
253
350
  if (!refreshToken || typeof refreshToken !== "string") {
254
351
  return null;
@@ -263,12 +360,11 @@ async function refreshAccessToken(refreshToken) {
263
360
  if (!res.ok)
264
361
  return null;
265
362
  const data = (await res.json());
266
- // Preserve existing user info from stored auth
267
363
  const existing = getAuthInfo();
268
364
  const tokens = {
269
365
  accessToken: data.access_token,
270
366
  refreshToken: data.refresh_token,
271
- expiresAt: Math.floor(Date.now() / 1000) + data.expires_in,
367
+ expiresAt: computeExpiresAt(data.access_token, data.expires_in),
272
368
  userId: existing?.userId ?? "",
273
369
  email: existing?.email ?? "",
274
370
  };
@@ -1,105 +1,18 @@
1
- export declare const CONFIG_DIR: string;
2
- export declare const CONFIG_FILE: string;
3
- export type Environment = "production" | "staging" | "local";
4
1
  /**
5
- * Resolve the active environment.
2
+ * Environment resolution and URL constants.
6
3
  *
7
- * Resolution order:
8
- * 1. `SMOLTBOT_ENV` environment variable
9
- * 2. Defaults to `production`
4
+ * UC-9 simplification: this module no longer manages a config file.
5
+ * Auth tokens live in auth.ts ~/.mnemom/auth.json.
6
+ * Agent resolution is server-side via api.ts resolveAgentId().
7
+ */
8
+ /** Base directory for mnemom CLI state (auth tokens, caches). */
9
+ export declare const MNEMOM_DIR: string;
10
+ export type Environment = "production" | "staging" | "local";
11
+ /**
12
+ * Resolve the active environment from MNEMOM_ENV.
13
+ * Defaults to production.
10
14
  */
11
15
  export declare function getEnvironment(): Environment;
12
16
  export declare function getApiUrl(): string;
13
17
  export declare function getGatewayUrl(): string;
14
18
  export declare function getWebsiteUrl(): string;
15
- export interface ConfigV1 {
16
- agentId: string;
17
- email?: string;
18
- gateway?: string;
19
- openclawConfigured?: boolean;
20
- providers?: string[];
21
- mnemomApiKey?: string;
22
- licenseJwt?: string;
23
- configuredAt?: string;
24
- }
25
- export interface AgentConfig {
26
- agentId: string;
27
- openclawConfigured?: boolean;
28
- providers?: string[];
29
- configuredAt?: string;
30
- }
31
- export interface AuthTokens {
32
- accessToken: string;
33
- refreshToken: string;
34
- expiresAt: number;
35
- userId: string;
36
- email: string;
37
- }
38
- export interface ConfigV2 {
39
- version: 2;
40
- defaultAgent: string;
41
- gateway: string;
42
- mnemomApiKey?: string;
43
- licenseJwt?: string;
44
- agents: Record<string, AgentConfig>;
45
- auth?: AuthTokens;
46
- }
47
- /** Backward-compatible alias so existing imports keep working. */
48
- export type Config = ConfigV2;
49
- /**
50
- * Migrate a v1 config into v2 format.
51
- * All agent-specific fields move into `agents.default`.
52
- */
53
- export declare function migrateConfig(raw: ConfigV1): ConfigV2;
54
- export declare function configExists(): boolean;
55
- /**
56
- * Load the config file.
57
- * If the file is v1 (no `version` field), it is automatically migrated to v2
58
- * and written back to disk before returning.
59
- */
60
- export declare function loadConfig(): ConfigV2 | null;
61
- export declare function saveConfig(config: ConfigV2): void;
62
- /**
63
- * Resolve the active agent config.
64
- *
65
- * Resolution order:
66
- * 1. Explicit `agentName` parameter (--agent flag)
67
- * 2. `SMOLTBOT_AGENT` environment variable
68
- *
69
- * Returns `null` if no agent is specified or the agent is not found.
70
- * Callers must require --agent or SMOLTBOT_AGENT for agent-scoped commands.
71
- */
72
- export declare function getActiveAgent(agentName?: string): AgentConfig | null;
73
- /**
74
- * Require an explicit agent selection. Exits with a helpful error if
75
- * no agent was specified via --agent or SMOLTBOT_AGENT.
76
- *
77
- * Falls back to API lookup if the agent is not in local config:
78
- * - smolt-XXXXXXXX IDs: public endpoint, no auth required
79
- * - Names: authenticated account listing (requires `smoltbot login`)
80
- */
81
- export declare function requireAgent(agentName?: string): Promise<AgentConfig>;
82
- export declare function generateAgentId(): string;
83
- /**
84
- * Compute the 16-char agent_hash for a given API key and optional agent name.
85
- * Matches the gateway's hashApiKey() and the POST /v1/agents/:id/rekey expected format.
86
- *
87
- * Unnamed agent: SHA256(apiKey).slice(0, 16)
88
- * Named agent: SHA256(apiKey + '|' + name).slice(0, 16)
89
- */
90
- export declare function computeAgentHash(apiKey: string, name?: string | null): string;
91
- /**
92
- * Derive agent ID deterministically from an API key.
93
- * Uses SHA-256 to match the gateway's hashApiKey (Web Crypto SHA-256, first 16 hex chars).
94
- * The agent ID is "smolt-" + first 8 hex chars of the SHA-256 digest.
95
- */
96
- export declare function deriveAgentId(apiKey: string): string;
97
- /**
98
- * Derive agent ID deterministically from an API key *and* a name.
99
- * Allows multiple named agents to share one API key with distinct IDs.
100
- * Uses SHA-256 to match the gateway's hashApiKey(apiKey + '|' + name).
101
- */
102
- export declare function deriveAgentIdWithName(apiKey: string, name: string): string;
103
- export declare function saveAuthTokens(tokens: AuthTokens): void;
104
- export declare function clearAuthTokens(): void;
105
- export declare function getAuthInfo(): AuthTokens | null;