@mnemom/mnemom 0.8.0 → 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/api.d.ts CHANGED
@@ -1,4 +1,14 @@
1
1
  export declare const API_BASE: string;
2
+ /**
3
+ * Generate a fresh Idempotency-Key for a mutation request.
4
+ *
5
+ * The server's beginIdempotentMutation contract (mnemom-api/src/idempotency.ts)
6
+ * requires this header on every PUT/POST/DELETE to a mutation endpoint;
7
+ * without it the server short-circuits with a 400 "Idempotency-Key header is
8
+ * required". Callers may pass an explicit key (for retries that must hit the
9
+ * cached reservation) — when omitted, we mint a UUIDv4.
10
+ */
11
+ export declare function newIdempotencyKey(): string;
2
12
  export interface Agent {
3
13
  id: string;
4
14
  gateway: string;
@@ -7,13 +17,33 @@ export interface Agent {
7
17
  email?: string;
8
18
  created_at: string;
9
19
  }
20
+ /**
21
+ * Per docs.mnemom.ai/api-reference/openapi.json#components/schemas/IntegrityScore:
22
+ *
23
+ * { agent_id, total_traces, verified_traces, violation_count, integrity_score }
24
+ *
25
+ * `integrity_score` is a value in [0, 1].
26
+ *
27
+ * Pre-this-fix the CLI's interface declared `score` / `verified` /
28
+ * `violations` / `last_updated` — none of which exist on the wire. The
29
+ * `mnemom integrity` command rendered every field as `undefined` (and the
30
+ * score as `NaN%`) against any agent. Field names now match the docs
31
+ * verbatim.
32
+ *
33
+ * NOTE — API shape divergence (flagged for a follow-up mnemom-api PR):
34
+ * the RPC path in handleGetIntegrity returns an *array* of one object
35
+ * (Supabase wraps TABLE-returning RPCs as arrays) without `agent_id`;
36
+ * the manual fallback returns the *object* with `agent_id`. The docs
37
+ * canonical is the object shape. We accept both shapes defensively in
38
+ * getIntegrity below so this CLI works against today's prod API and
39
+ * keeps working when the API normalizes to a single shape.
40
+ */
10
41
  export interface IntegrityScore {
11
- agent_id: string;
12
- score: number;
42
+ agent_id?: string;
13
43
  total_traces: number;
14
- verified: number;
15
- violations: number;
16
- last_updated: string;
44
+ verified_traces: number;
45
+ violation_count: number;
46
+ integrity_score: number;
17
47
  }
18
48
  export interface Trace {
19
49
  id: string;
@@ -29,7 +59,9 @@ export interface ApiError {
29
59
  error: string;
30
60
  message: string;
31
61
  }
32
- export declare function postApi<T>(endpoint: string, body: unknown): Promise<T>;
62
+ export declare function postApi<T>(endpoint: string, body: unknown, opts?: {
63
+ idempotencyKey?: string;
64
+ }): Promise<T>;
33
65
  export declare function verifyBinding(agentId: string, keyHash: string): Promise<{
34
66
  bound: boolean;
35
67
  key_prefix: string | null;
@@ -53,7 +85,30 @@ export declare function listAgents(): Promise<AgentListItem[]>;
53
85
  * Note: capped at 100 agents by listAgents().
54
86
  */
55
87
  export declare function getAgentByName(name: string): Promise<AgentListItem | null>;
88
+ /**
89
+ * Fetch the integrity score for an agent.
90
+ *
91
+ * `/v1/integrity/:id` is the public-when-agent-is-public, owner-otherwise
92
+ * data route. The CLI must send the owner's auth header so private claimed
93
+ * agents (the common case for customers and internal users) return 200
94
+ * instead of 401. fetchWithAuthRetry inherits the 401-retry-with-refresh
95
+ * path from PR #200 so a stale local expiresAt heals transparently.
96
+ */
56
97
  export declare function getIntegrity(id: string): Promise<IntegrityScore>;
98
+ /**
99
+ * Fetch recent traces for an agent.
100
+ *
101
+ * The API returns an envelope `{ traces, limit, offset }` (see
102
+ * mnemom-api/src/index.ts:handleGetTraces). Pre-this-fix the CLI typed
103
+ * the response as `Trace[]` and downstream code crashed with "traces is
104
+ * not iterable" because the envelope is not iterable. We unwrap the
105
+ * envelope here and return the bare array so callers (logs.ts) can keep
106
+ * the simpler shape.
107
+ *
108
+ * Sends auth headers — same reasoning as getIntegrity. The pre-PR-A
109
+ * `{ traces: [], private: true }` leaky envelope branch is gone, so we
110
+ * don't need a special case for it.
111
+ */
57
112
  export declare function getTraces(id: string, limit?: number): Promise<Trace[]>;
58
113
  export interface AlignmentCard {
59
114
  card_id?: string;
@@ -93,11 +148,15 @@ export interface CardResponse {
93
148
  updated_at: string;
94
149
  }
95
150
  export declare function getCard(agentId: string): Promise<CardResponse | null>;
96
- export declare function updateCard(agentId: string, cardJson: AlignmentCard): Promise<{
151
+ export declare function updateCard(agentId: string, cardJson: AlignmentCard, opts?: {
152
+ idempotencyKey?: string;
153
+ }): Promise<{
97
154
  updated: boolean;
98
155
  card_id: string;
99
156
  }>;
100
- export declare function reverifyAgent(agentId: string): Promise<{
157
+ export declare function reverifyAgent(agentId: string, opts?: {
158
+ idempotencyKey?: string;
159
+ }): Promise<{
101
160
  reverified: number;
102
161
  }>;
103
162
  export interface PolicyResponse {
@@ -114,7 +173,9 @@ export interface PolicyListResponse {
114
173
  policy: PolicyResponse | null;
115
174
  }
116
175
  export declare function getPolicy(agentId: string): Promise<PolicyListResponse | null>;
117
- export declare function publishPolicy(agentId: string, policyJson: Record<string, unknown>): Promise<{
176
+ export declare function publishPolicy(agentId: string, policyJson: Record<string, unknown>, opts?: {
177
+ idempotencyKey?: string;
178
+ }): Promise<{
118
179
  id: string;
119
180
  version: number;
120
181
  created: boolean;
@@ -139,14 +200,33 @@ export declare function getAlignmentCard(agentId: string, format?: "yaml" | "jso
139
200
  body: string;
140
201
  contentType: string;
141
202
  }>;
203
+ /** Hard cap enforced by the API on inbound alignment-card bodies (413 otherwise). */
204
+ export declare const ALIGNMENT_CARD_MAX_BYTES: number;
205
+ /**
206
+ * Shape of a successful PUT response — the canonical card the composer
207
+ * just wrote, serialized as JSON via mnemom-api/src/composition/response.ts:
208
+ * respondCard with `_composition` stripped. We type the fields we display;
209
+ * the rest of the canonical card is allowed (and ignored at this layer).
210
+ *
211
+ * Pre-this-fix the typed return was `{ card_id, composed: boolean }` —
212
+ * a fictional wrapper. The API never returned a `composed` field; the CLI
213
+ * was rendering "Canonical card recomposed" gated on a value that, on the
214
+ * real wire, is always undefined. Drop the lie; reflect the actual shape.
215
+ */
216
+ export interface PublishedCardResponse {
217
+ card_id?: string;
218
+ agent_id?: string;
219
+ card_version?: string;
220
+ issued_at?: string;
221
+ [key: string]: unknown;
222
+ }
142
223
  /**
143
224
  * Publish (create or update) an alignment card.
144
225
  * Accepts YAML or JSON body; set contentType accordingly.
145
226
  */
146
- export declare function putAlignmentCard(agentId: string, body: string, contentType?: "text/yaml" | "application/json"): Promise<{
147
- card_id: string;
148
- composed: boolean;
149
- }>;
227
+ export declare function putAlignmentCard(agentId: string, body: string, contentType?: "text/yaml" | "application/json", opts?: {
228
+ idempotencyKey?: string;
229
+ }): Promise<PublishedCardResponse>;
150
230
  /**
151
231
  * Fetch the canonical protection card as YAML (or JSON fallback).
152
232
  */
@@ -154,13 +234,14 @@ export declare function getProtectionCard(agentId: string, format?: "yaml" | "js
154
234
  body: string;
155
235
  contentType: string;
156
236
  }>;
237
+ /** Hard cap enforced by the API on inbound protection-card bodies (413 otherwise). */
238
+ export declare const PROTECTION_CARD_MAX_BYTES: number;
157
239
  /**
158
240
  * Publish (create or update) a protection card.
159
241
  */
160
- export declare function putProtectionCard(agentId: string, body: string, contentType?: "text/yaml" | "application/json"): Promise<{
161
- card_id: string;
162
- composed: boolean;
163
- }>;
242
+ export declare function putProtectionCard(agentId: string, body: string, contentType?: "text/yaml" | "application/json", opts?: {
243
+ idempotencyKey?: string;
244
+ }): Promise<PublishedCardResponse>;
164
245
  /**
165
246
  * Resolve an agent name or ID to a server agent ID.
166
247
  *
package/dist/lib/api.js CHANGED
@@ -1,5 +1,6 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { getApiUrl } from "./config.js";
2
- import { resolveAuth } from "./auth.js";
3
+ import { forceRefreshAccessToken, resolveAuth } from "./auth.js";
3
4
  export const API_BASE = getApiUrl();
4
5
  /** Sanitize file-sourced data before use in outbound HTTP requests. */
5
6
  function sanitizeForHttp(data) {
@@ -13,6 +14,18 @@ function validateUrl(url) {
13
14
  }
14
15
  return parsed.href;
15
16
  }
17
+ /**
18
+ * Generate a fresh Idempotency-Key for a mutation request.
19
+ *
20
+ * The server's beginIdempotentMutation contract (mnemom-api/src/idempotency.ts)
21
+ * requires this header on every PUT/POST/DELETE to a mutation endpoint;
22
+ * without it the server short-circuits with a 400 "Idempotency-Key header is
23
+ * required". Callers may pass an explicit key (for retries that must hit the
24
+ * cached reservation) — when omitted, we mint a UUIDv4.
25
+ */
26
+ export function newIdempotencyKey() {
27
+ return randomUUID();
28
+ }
16
29
  async function fetchApi(endpoint) {
17
30
  const url = validateUrl(`${API_BASE}${endpoint}`);
18
31
  const response = await fetch(url);
@@ -25,11 +38,12 @@ async function fetchApi(endpoint) {
25
38
  }
26
39
  return response.json();
27
40
  }
28
- export async function postApi(endpoint, body) {
41
+ export async function postApi(endpoint, body, opts = {}) {
29
42
  const url = validateUrl(`${API_BASE}${endpoint}`);
30
43
  const cred = await resolveAuth();
31
44
  const headers = {
32
45
  "Content-Type": "application/json",
46
+ "Idempotency-Key": opts.idempotencyKey ?? newIdempotencyKey(),
33
47
  };
34
48
  if (cred.type === "jwt") {
35
49
  headers["Authorization"] = `Bearer ${cred.token}`;
@@ -74,12 +88,41 @@ async function authHeaders() {
74
88
  return {};
75
89
  }
76
90
  }
91
+ /**
92
+ * Issue an authenticated fetch, and on a 401 response transparently force a
93
+ * token refresh and retry once.
94
+ *
95
+ * We hit this in the wild when the locally-stored expiresAt diverges from
96
+ * the JWT's actual exp claim (e.g. Supabase has been observed reporting
97
+ * expires_in values longer than the JWT's exp). Without this retry,
98
+ * `whoami` cheerfully reports a "valid" token while every authenticated
99
+ * call gets 401 — and the user has no path forward besides
100
+ * `mnemom logout && mnemom login`. The retry heals stale auth files
101
+ * transparently for users who haven't re-logged-in since the
102
+ * computeExpiresAt fix landed.
103
+ *
104
+ * `buildInit` is invoked fresh for each attempt so the retry picks up the
105
+ * new Authorization header from the refreshed token. We do NOT mint a new
106
+ * Idempotency-Key on the retry — the same key keys back into the same
107
+ * server-side reservation by design.
108
+ */
109
+ async function fetchWithAuthRetry(url, buildInit) {
110
+ const first = await fetch(url, await buildInit());
111
+ if (first.status !== 401)
112
+ return first;
113
+ const refreshed = await forceRefreshAccessToken();
114
+ if (!refreshed)
115
+ return first;
116
+ return fetch(url, await buildInit());
117
+ }
77
118
  export async function getAgent(id) {
78
119
  return fetchApi(`/v1/agents/${id}`);
79
120
  }
80
121
  export async function listAgents() {
81
122
  const url = validateUrl(`${API_BASE}/v1/agents?limit=100`);
82
- const response = await fetch(url, { headers: await authHeaders() });
123
+ const response = await fetchWithAuthRetry(url, async () => ({
124
+ headers: await authHeaders(),
125
+ }));
83
126
  if (!response.ok) {
84
127
  if (response.status === 401) {
85
128
  throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
@@ -112,11 +155,79 @@ export async function getAgentByName(name) {
112
155
  }
113
156
  return null;
114
157
  }
158
+ /**
159
+ * Fetch the integrity score for an agent.
160
+ *
161
+ * `/v1/integrity/:id` is the public-when-agent-is-public, owner-otherwise
162
+ * data route. The CLI must send the owner's auth header so private claimed
163
+ * agents (the common case for customers and internal users) return 200
164
+ * instead of 401. fetchWithAuthRetry inherits the 401-retry-with-refresh
165
+ * path from PR #200 so a stale local expiresAt heals transparently.
166
+ */
115
167
  export async function getIntegrity(id) {
116
- return fetchApi(`/v1/integrity/${id}`);
168
+ const url = validateUrl(`${API_BASE}/v1/integrity/${id}`);
169
+ const response = await fetchWithAuthRetry(url, async () => ({
170
+ headers: await authHeaders(),
171
+ }));
172
+ if (!response.ok) {
173
+ const error = (await response.json().catch(() => ({
174
+ error: "unknown",
175
+ message: response.statusText,
176
+ })));
177
+ throw new Error(error.message || `API request failed: ${response.status}`);
178
+ }
179
+ // Accept both the canonical docs shape (object) and the RPC-wrapped shape
180
+ // (array of one row). The latter is what prod actually returns today on
181
+ // the RPC path; flagged for a follow-up API normalization.
182
+ const body = (await response.json());
183
+ const row = Array.isArray(body) ? (body[0] ?? emptyIntegrityRow(id)) : body;
184
+ // Fill in agent_id when the API didn't (RPC path) so the field is always
185
+ // populated for consumers regardless of which API path served the request.
186
+ if (!row.agent_id)
187
+ row.agent_id = id;
188
+ return row;
117
189
  }
190
+ function emptyIntegrityRow(agentId) {
191
+ return {
192
+ agent_id: agentId,
193
+ total_traces: 0,
194
+ verified_traces: 0,
195
+ violation_count: 0,
196
+ integrity_score: 1,
197
+ };
198
+ }
199
+ /**
200
+ * Fetch recent traces for an agent.
201
+ *
202
+ * The API returns an envelope `{ traces, limit, offset }` (see
203
+ * mnemom-api/src/index.ts:handleGetTraces). Pre-this-fix the CLI typed
204
+ * the response as `Trace[]` and downstream code crashed with "traces is
205
+ * not iterable" because the envelope is not iterable. We unwrap the
206
+ * envelope here and return the bare array so callers (logs.ts) can keep
207
+ * the simpler shape.
208
+ *
209
+ * Sends auth headers — same reasoning as getIntegrity. The pre-PR-A
210
+ * `{ traces: [], private: true }` leaky envelope branch is gone, so we
211
+ * don't need a special case for it.
212
+ */
118
213
  export async function getTraces(id, limit = 10) {
119
- return fetchApi(`/v1/traces?agent_id=${id}&limit=${limit}`);
214
+ const url = validateUrl(`${API_BASE}/v1/traces?agent_id=${id}&limit=${limit}`);
215
+ const response = await fetchWithAuthRetry(url, async () => ({
216
+ headers: await authHeaders(),
217
+ }));
218
+ if (!response.ok) {
219
+ const error = (await response.json().catch(() => ({
220
+ error: "unknown",
221
+ message: response.statusText,
222
+ })));
223
+ throw new Error(error.message || `API request failed: ${response.status}`);
224
+ }
225
+ const data = (await response.json());
226
+ // Accept both the envelope shape (current API) and a bare array (defensive
227
+ // — staging is mid-deploy when a CLI smoke runs against an older API).
228
+ if (Array.isArray(data))
229
+ return data;
230
+ return data.traces ?? [];
120
231
  }
121
232
  export async function getCard(agentId) {
122
233
  try {
@@ -130,11 +241,15 @@ export async function getCard(agentId) {
130
241
  throw error;
131
242
  }
132
243
  }
133
- export async function updateCard(agentId, cardJson) {
244
+ export async function updateCard(agentId, cardJson, opts = {}) {
134
245
  const url = validateUrl(`${API_BASE}/v1/agents/${agentId}/card`);
135
246
  const response = await fetch(url, {
136
247
  method: "PATCH",
137
- headers: { "Content-Type": "application/json", ...(await authHeaders()) },
248
+ headers: {
249
+ "Content-Type": "application/json",
250
+ "Idempotency-Key": opts.idempotencyKey ?? newIdempotencyKey(),
251
+ ...(await authHeaders()),
252
+ },
138
253
  body: sanitizeForHttp(JSON.stringify({ card_json: cardJson })),
139
254
  });
140
255
  if (!response.ok) {
@@ -146,11 +261,15 @@ export async function updateCard(agentId, cardJson) {
146
261
  }
147
262
  return response.json();
148
263
  }
149
- export async function reverifyAgent(agentId) {
264
+ export async function reverifyAgent(agentId, opts = {}) {
150
265
  const url = validateUrl(`${API_BASE}/v1/agents/${agentId}/reverify`);
151
266
  const response = await fetch(url, {
152
267
  method: "POST",
153
- headers: { "Content-Type": "application/json", ...(await authHeaders()) },
268
+ headers: {
269
+ "Content-Type": "application/json",
270
+ "Idempotency-Key": opts.idempotencyKey ?? newIdempotencyKey(),
271
+ ...(await authHeaders()),
272
+ },
154
273
  });
155
274
  if (!response.ok) {
156
275
  const error = (await response.json().catch(() => ({
@@ -173,11 +292,15 @@ export async function getPolicy(agentId) {
173
292
  throw error;
174
293
  }
175
294
  }
176
- export async function publishPolicy(agentId, policyJson) {
295
+ export async function publishPolicy(agentId, policyJson, opts = {}) {
177
296
  const url = validateUrl(`${API_BASE}/v1/agents/${agentId}/policy`);
178
297
  const response = await fetch(url, {
179
298
  method: "PUT",
180
- headers: { "Content-Type": "application/json", ...(await authHeaders()) },
299
+ headers: {
300
+ "Content-Type": "application/json",
301
+ "Idempotency-Key": opts.idempotencyKey ?? newIdempotencyKey(),
302
+ ...(await authHeaders()),
303
+ },
181
304
  body: sanitizeForHttp(JSON.stringify({ policy_json: policyJson })),
182
305
  });
183
306
  if (!response.ok) {
@@ -232,17 +355,37 @@ export async function getAlignmentCard(agentId, format = "yaml") {
232
355
  const body = await response.text();
233
356
  return { body, contentType: ct };
234
357
  }
358
+ /** Hard cap enforced by the API on inbound alignment-card bodies (413 otherwise). */
359
+ export const ALIGNMENT_CARD_MAX_BYTES = 128 * 1024;
235
360
  /**
236
361
  * Publish (create or update) an alignment card.
237
362
  * Accepts YAML or JSON body; set contentType accordingly.
238
363
  */
239
- export async function putAlignmentCard(agentId, body, contentType = "text/yaml") {
364
+ export async function putAlignmentCard(agentId, body, contentType = "text/yaml", opts = {}) {
240
365
  const url = validateUrl(`${API_BASE}/v1/agents/${agentId}/alignment-card`);
241
- const response = await fetch(url, {
366
+ // Lock in a single Idempotency-Key for this logical mutation. If the auth
367
+ // token is stale and the first attempt returns 401, fetchWithAuthRetry
368
+ // refreshes the token and retries — but the Idempotency-Key must be the
369
+ // same key on both attempts so the server's reservation table sees them as
370
+ // a single mutation, not two competing PUTs.
371
+ const idempotencyKey = opts.idempotencyKey ?? newIdempotencyKey();
372
+ const sanitizedBody = sanitizeForHttp(body);
373
+ const response = await fetchWithAuthRetry(url, async () => ({
242
374
  method: "PUT",
243
- headers: { "Content-Type": contentType, ...(await authHeaders()) },
244
- body: sanitizeForHttp(body),
245
- });
375
+ headers: {
376
+ "Content-Type": contentType,
377
+ // The API content-negotiates the response body via Accept (see
378
+ // mnemom-api/src/composition/response.ts:respondYamlJson). We parse the
379
+ // response as JSON below, so we have to ask for JSON explicitly —
380
+ // Node's default Accept: */* would otherwise yield a YAML body and
381
+ // response.json() would crash with "Unexpected token 'a' is not valid
382
+ // JSON" against the canonical card we just wrote.
383
+ Accept: "application/json",
384
+ "Idempotency-Key": idempotencyKey,
385
+ ...(await authHeaders()),
386
+ },
387
+ body: sanitizedBody,
388
+ }));
246
389
  if (!response.ok) {
247
390
  const error = (await response.json().catch(() => ({
248
391
  error: "unknown",
@@ -275,16 +418,29 @@ export async function getProtectionCard(agentId, format = "yaml") {
275
418
  const body = await response.text();
276
419
  return { body, contentType: ct };
277
420
  }
421
+ /** Hard cap enforced by the API on inbound protection-card bodies (413 otherwise). */
422
+ export const PROTECTION_CARD_MAX_BYTES = 64 * 1024;
278
423
  /**
279
424
  * Publish (create or update) a protection card.
280
425
  */
281
- export async function putProtectionCard(agentId, body, contentType = "text/yaml") {
426
+ export async function putProtectionCard(agentId, body, contentType = "text/yaml", opts = {}) {
282
427
  const url = validateUrl(`${API_BASE}/v1/agents/${agentId}/protection-card`);
283
- const response = await fetch(url, {
428
+ // See putAlignmentCard for why the Idempotency-Key is computed once outside
429
+ // the retry closure.
430
+ const idempotencyKey = opts.idempotencyKey ?? newIdempotencyKey();
431
+ const sanitizedBody = sanitizeForHttp(body);
432
+ const response = await fetchWithAuthRetry(url, async () => ({
284
433
  method: "PUT",
285
- headers: { "Content-Type": contentType, ...(await authHeaders()) },
286
- body: sanitizeForHttp(body),
287
- });
434
+ headers: {
435
+ "Content-Type": contentType,
436
+ // See putAlignmentCard for the rationale — the API negotiates response
437
+ // body format via Accept and we parse the response as JSON below.
438
+ Accept: "application/json",
439
+ "Idempotency-Key": idempotencyKey,
440
+ ...(await authHeaders()),
441
+ },
442
+ body: sanitizedBody,
443
+ }));
288
444
  if (!response.ok) {
289
445
  const error = (await response.json().catch(() => ({
290
446
  error: "unknown",
@@ -30,6 +30,12 @@ export type AuthCredential = {
30
30
  } | {
31
31
  type: "none";
32
32
  };
33
+ /**
34
+ * Compute the effective expiresAt for a freshly issued access token.
35
+ * Prefers the JWT's own `exp` claim; falls back to `now + expires_in` if the
36
+ * token can't be parsed (e.g. an opaque token).
37
+ */
38
+ export declare function computeExpiresAt(accessToken: string, expiresInSeconds: number): number;
33
39
  /**
34
40
  * Get a valid access token, or null if not authenticated.
35
41
  *
@@ -38,6 +44,16 @@ export type AuthCredential = {
38
44
  * 2. Stored token from auth store (auto-refreshes if expired)
39
45
  */
40
46
  export declare function getAccessToken(): Promise<string | null>;
47
+ /**
48
+ * Force a refresh of the stored access token regardless of local expiry, and
49
+ * return the new access token (or null if refresh failed).
50
+ *
51
+ * Intended as a 401-recovery hook for callers: if an authenticated request
52
+ * comes back unauthorized despite the local cache claiming a valid token, the
53
+ * cache is stale (clock skew, divergence between expires_in and the JWT's
54
+ * actual exp, or server-side revocation). Force a refresh and retry once.
55
+ */
56
+ export declare function forceRefreshAccessToken(): Promise<string | null>;
41
57
  /**
42
58
  * Get a valid access token or exit with a helpful message.
43
59
  */
package/dist/lib/auth.js CHANGED
@@ -75,6 +75,37 @@ export function getLicenseJwt() {
75
75
  function sanitizeForHttp(data) {
76
76
  return String(data).trim();
77
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
+ }
78
109
  /**
79
110
  * Get a valid access token, or null if not authenticated.
80
111
  *
@@ -100,6 +131,25 @@ export async function getAccessToken() {
100
131
  return refreshed.accessToken;
101
132
  return null;
102
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
+ }
103
153
  /**
104
154
  * Get a valid access token or exit with a helpful message.
105
155
  */
@@ -218,7 +268,7 @@ async function startCallbackServer(expectedState) {
218
268
  const tokens = {
219
269
  accessToken: data.access_token,
220
270
  refreshToken: data.refresh_token,
221
- expiresAt: Math.floor(Date.now() / 1000) + data.expires_in,
271
+ expiresAt: computeExpiresAt(data.access_token, data.expires_in),
222
272
  userId: data.user_id,
223
273
  email: data.user_email,
224
274
  };
@@ -286,7 +336,7 @@ export async function loginWithPassword(email, password) {
286
336
  const tokens = {
287
337
  accessToken: data.access_token,
288
338
  refreshToken: data.refresh_token,
289
- expiresAt: Math.floor(Date.now() / 1000) + data.expires_in,
339
+ expiresAt: computeExpiresAt(data.access_token, data.expires_in),
290
340
  userId: data.user.id,
291
341
  email: data.user.email,
292
342
  };
@@ -314,7 +364,7 @@ async function refreshAccessToken(refreshToken) {
314
364
  const tokens = {
315
365
  accessToken: data.access_token,
316
366
  refreshToken: data.refresh_token,
317
- expiresAt: Math.floor(Date.now() / 1000) + data.expires_in,
367
+ expiresAt: computeExpiresAt(data.access_token, data.expires_in),
318
368
  userId: existing?.userId ?? "",
319
369
  email: existing?.email ?? "",
320
370
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {