@ze-norm/cli 0.11.2 → 0.11.3

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.
@@ -11,12 +11,15 @@ export interface AuthContext {
11
11
  }
12
12
  export declare class ZenormClient {
13
13
  private baseUrl;
14
- private token;
14
+ private explicitToken;
15
15
  constructor(opts?: ApiClientOptions);
16
- private getHeaders;
16
+ /** Resolve the bearer token for a request, refreshing it if expired. */
17
+ private resolveRequestToken;
18
+ private buildHeaders;
17
19
  private isLocalBaseUrl;
18
20
  /** True when this client targets a localhost API (dev-bypass eligible). */
19
21
  isLocalDevTarget(): boolean;
22
+ private sendOnce;
20
23
  private request;
21
24
  get<T>(path: string): Promise<T>;
22
25
  post<T>(path: string, body?: unknown): Promise<T>;
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"AAqBA,kFAAkF;AAClF,wBAAgB,qBAAqB,IAAI,IAAI,CAE5C;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA0BD,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,qBAAa,YAAY;IACvB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,KAAK,CAAgB;gBAEjB,IAAI,CAAC,EAAE,gBAAgB;IAKnC,OAAO,CAAC,UAAU;IA0BlB,OAAO,CAAC,cAAc;IAStB,2EAA2E;IAC3E,gBAAgB,IAAI,OAAO;YAIb,OAAO;IAsEf,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC;IAIhC,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC;IAIjD,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC;IAIhD,KAAK,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC;IAIlD,MAAM,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC;IAIzC;;OAEG;IACG,cAAc,IAAI,OAAO,CAAC,WAAW,CAAC;CAG7C"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"AAyBA,kFAAkF;AAClF,wBAAgB,qBAAqB,IAAI,IAAI,CAE5C;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA0BD,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,qBAAa,YAAY;IACvB,OAAO,CAAC,OAAO,CAAS;IAIxB,OAAO,CAAC,aAAa,CAAgB;gBAEzB,IAAI,CAAC,EAAE,gBAAgB;IAKnC,wEAAwE;YAC1D,mBAAmB;IAKjC,OAAO,CAAC,YAAY;IA0BpB,OAAO,CAAC,cAAc;IAStB,2EAA2E;IAC3E,gBAAgB,IAAI,OAAO;YAIb,QAAQ;YAqBR,OAAO;IAqEf,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC;IAIhC,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC;IAIjD,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC;IAIhD,KAAK,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC;IAIlD,MAAM,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC;IAIzC;;OAEG;IACG,cAAc,IAAI,OAAO,CAAC,WAAW,CAAC;CAG7C"}
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { ApiError, AuthError, UpgradeRequiredError } from "../util/errors.js";
4
- import { resolveToken } from "../auth/store.js";
4
+ import { forceRefreshToken, hasRefreshableCredentials, resolveValidToken, } from "../auth/store.js";
5
5
  import { PRODUCTION_API_URL } from "../config/defaults.js";
6
6
  import { getCliVersion } from "../util/package.js";
7
7
  import { log } from "../util/logger.js";
@@ -40,12 +40,21 @@ function resolveBaseUrl(explicit) {
40
40
  }
41
41
  export class ZenormClient {
42
42
  baseUrl;
43
- token;
43
+ // An explicit token override (e.g. from `login` before creds are persisted).
44
+ // When set it is used verbatim and never refreshed. When null, the token is
45
+ // resolved per-request from stored credentials so expiry/refresh is handled.
46
+ explicitToken;
44
47
  constructor(opts) {
45
48
  this.baseUrl = resolveBaseUrl(opts?.baseUrl).replace(/\/+$/, "");
46
- this.token = opts?.token ?? resolveToken();
49
+ this.explicitToken = opts?.token ?? null;
47
50
  }
48
- getHeaders() {
51
+ /** Resolve the bearer token for a request, refreshing it if expired. */
52
+ async resolveRequestToken() {
53
+ if (this.explicitToken)
54
+ return this.explicitToken;
55
+ return resolveValidToken();
56
+ }
57
+ buildHeaders(token) {
49
58
  const headers = {
50
59
  "Content-Type": "application/json",
51
60
  // Always advertise the CLI version so the API can gate stale clients
@@ -54,8 +63,8 @@ export class ZenormClient {
54
63
  // authenticated or dev-bypassed.
55
64
  "x-zenorm-cli-version": getCliVersion(),
56
65
  };
57
- if (this.token) {
58
- headers["Authorization"] = `Bearer ${this.token}`;
66
+ if (token) {
67
+ headers["Authorization"] = `Bearer ${token}`;
59
68
  return headers;
60
69
  }
61
70
  if (this.isLocalBaseUrl()) {
@@ -82,23 +91,38 @@ export class ZenormClient {
82
91
  isLocalDevTarget() {
83
92
  return this.isLocalBaseUrl();
84
93
  }
85
- async request(method, path, body) {
86
- const url = `${this.baseUrl}${path}`;
94
+ async sendOnce(method, url, token, body) {
87
95
  const init = {
88
96
  method,
89
- headers: this.getHeaders(),
97
+ headers: this.buildHeaders(token),
90
98
  };
91
99
  if (body !== undefined) {
92
100
  init.body = JSON.stringify(body);
93
101
  }
94
- let res;
95
102
  try {
96
- res = await fetch(url, init);
103
+ return await fetch(url, init);
97
104
  }
98
105
  catch (err) {
99
106
  const msg = err instanceof Error ? err.message : String(err);
100
107
  throw new ApiError(`Request failed: ${msg}`, 0, null);
101
108
  }
109
+ }
110
+ async request(method, path, body) {
111
+ const url = `${this.baseUrl}${path}`;
112
+ let token = await this.resolveRequestToken();
113
+ let res = await this.sendOnce(method, url, token, body);
114
+ // A 401 on a token we resolved from stored credentials may mean the server
115
+ // revoked it before its stated expiry. Try one forced refresh + retry
116
+ // before giving up — but never for an explicit/env token we can't refresh.
117
+ if (res.status === 401 && !this.explicitToken && hasRefreshableCredentials()) {
118
+ try {
119
+ token = await forceRefreshToken();
120
+ }
121
+ catch {
122
+ throw new AuthError("Authentication required. Run `zenorm login` first.");
123
+ }
124
+ res = await this.sendOnce(method, url, token, body);
125
+ }
102
126
  if (res.status === 401) {
103
127
  throw new AuthError("Authentication required. Run `zenorm login` first.");
104
128
  }
@@ -1,4 +1,18 @@
1
1
  import type { StoredCredentials } from "./store.js";
2
+ /**
3
+ * Exchange a stored refresh token for a fresh access token at the Clerk token
4
+ * endpoint (`grant_type=refresh_token`). Clerk may rotate the refresh token, so
5
+ * the caller must persist whichever refresh token comes back; if the response
6
+ * omits one, the existing refresh token is still valid and is returned as-is.
7
+ *
8
+ * Throws on any non-2xx response — an expired/revoked refresh token means the
9
+ * caller must fall back to a full `zenorm login`.
10
+ */
11
+ export declare function refreshAccessToken(refreshToken: string): Promise<{
12
+ accessToken: string;
13
+ refreshToken: string;
14
+ expiresIn: number;
15
+ }>;
2
16
  /**
3
17
  * Run the full OAuth 2.0 Authorization Code + PKCE flow.
4
18
  *
@@ -1 +1 @@
1
- {"version":3,"file":"device-flow.d.ts","sourceRoot":"","sources":["../../src/auth/device-flow.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAkUpD;;;;;;;;;GASG;AACH,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,iBAAiB,CAAC,CAkDpE"}
1
+ {"version":3,"file":"device-flow.d.ts","sourceRoot":"","sources":["../../src/auth/device-flow.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAkUpD;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC;IACtE,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC,CAuBD;AAED;;;;;;;;;GASG;AACH,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,iBAAiB,CAAC,CAkDpE"}
@@ -280,6 +280,37 @@ async function exchangeCodeForTokens(code, codeVerifier, redirectUri) {
280
280
  expiresIn: data.expires_in ?? 3600,
281
281
  };
282
282
  }
283
+ /**
284
+ * Exchange a stored refresh token for a fresh access token at the Clerk token
285
+ * endpoint (`grant_type=refresh_token`). Clerk may rotate the refresh token, so
286
+ * the caller must persist whichever refresh token comes back; if the response
287
+ * omits one, the existing refresh token is still valid and is returned as-is.
288
+ *
289
+ * Throws on any non-2xx response — an expired/revoked refresh token means the
290
+ * caller must fall back to a full `zenorm login`.
291
+ */
292
+ export async function refreshAccessToken(refreshToken) {
293
+ const res = await fetch(`${getClerkIssuer()}/oauth/token`, {
294
+ method: "POST",
295
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
296
+ body: new URLSearchParams({
297
+ grant_type: "refresh_token",
298
+ refresh_token: refreshToken,
299
+ client_id: getClerkClientId(),
300
+ }),
301
+ });
302
+ if (!res.ok) {
303
+ const text = await res.text().catch(() => "");
304
+ throw new Error(`Token refresh failed (${res.status}): ${text}`);
305
+ }
306
+ const data = (await res.json());
307
+ return {
308
+ accessToken: data.access_token,
309
+ // Clerk rotates refresh tokens; reuse the old one only if none is returned.
310
+ refreshToken: data.refresh_token ?? refreshToken,
311
+ expiresIn: data.expires_in ?? 3600,
312
+ };
313
+ }
283
314
  /**
284
315
  * Run the full OAuth 2.0 Authorization Code + PKCE flow.
285
316
  *
@@ -9,8 +9,38 @@ export declare function loadCredentials(): StoredCredentials | null;
9
9
  export declare function saveCredentials(creds: StoredCredentials): void;
10
10
  export declare function deleteCredentials(): boolean;
11
11
  /**
12
- * Resolve the current access token.
12
+ * Resolve the current access token without any network/refresh side effects.
13
13
  * Priority: ZENORM_API_TOKEN env var > stored credentials.
14
+ *
15
+ * Prefer `resolveValidToken()` for actual API calls — this raw accessor does
16
+ * NOT check expiry and is kept for callers that only need the literal token
17
+ * (e.g. displaying it).
14
18
  */
15
19
  export declare function resolveToken(): string | null;
20
+ /**
21
+ * Resolve a usable access token, refreshing it first when the stored one is
22
+ * expired (or about to be). Rotated credentials are persisted.
23
+ *
24
+ * Priority: ZENORM_API_TOKEN env var (used as-is, never refreshed) > stored
25
+ * credentials (refreshed on demand). Returns null when there is nothing to
26
+ * resolve, leaving the caller to fall back to dev-bypass headers or error.
27
+ *
28
+ * A failed refresh (revoked/expired refresh token) throws; the caller surfaces
29
+ * it as "run `zenorm login`".
30
+ */
31
+ export declare function resolveValidToken(): Promise<string | null>;
32
+ /**
33
+ * True when a refresh-token grant is possible: stored credentials with a
34
+ * refresh token exist and no explicit ZENORM_API_TOKEN override is in play
35
+ * (an env token is opaque to us and cannot be refreshed).
36
+ */
37
+ export declare function hasRefreshableCredentials(): boolean;
38
+ /**
39
+ * Force a refresh regardless of expiry — used after a 401, where the server
40
+ * rejected a token that may have been revoked before its stated expiry.
41
+ * Persists rotated credentials and returns the new access token.
42
+ *
43
+ * Throws if there is nothing to refresh or the refresh grant fails.
44
+ */
45
+ export declare function forceRefreshToken(): Promise<string>;
16
46
  //# sourceMappingURL=store.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/auth/store.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAaD,wBAAgB,eAAe,IAAI,iBAAiB,GAAG,IAAI,CAO1D;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,iBAAiB,GAAG,IAAI,CAM9D;AAED,wBAAgB,iBAAiB,IAAI,OAAO,CAO3C;AAED;;;GAGG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAU5C"}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/auth/store.ts"],"names":[],"mappings":"AAUA,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAaD,wBAAgB,eAAe,IAAI,iBAAiB,GAAG,IAAI,CAO1D;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,iBAAiB,GAAG,IAAI,CAM9D;AAED,wBAAgB,iBAAiB,IAAI,OAAO,CAO3C;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAU5C;AAWD;;;;;;;;;;GAUG;AACH,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAwBhE;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,IAAI,OAAO,CAInD;AAED;;;;;;GAMG;AACH,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,MAAM,CAAC,CAezD"}
@@ -1,6 +1,11 @@
1
1
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { refreshAccessToken } from "./device-flow.js";
5
+ import { log } from "../util/logger.js";
6
+ // Refresh the access token this many ms before it actually expires, so an
7
+ // in-flight request never races the expiry boundary.
8
+ const EXPIRY_SKEW_MS = 60_000;
4
9
  function getCredentialsPath() {
5
10
  return join(homedir(), ".zenorm", "credentials.json");
6
11
  }
@@ -34,8 +39,12 @@ export function deleteCredentials() {
34
39
  return true;
35
40
  }
36
41
  /**
37
- * Resolve the current access token.
42
+ * Resolve the current access token without any network/refresh side effects.
38
43
  * Priority: ZENORM_API_TOKEN env var > stored credentials.
44
+ *
45
+ * Prefer `resolveValidToken()` for actual API calls — this raw accessor does
46
+ * NOT check expiry and is kept for callers that only need the literal token
47
+ * (e.g. displaying it).
39
48
  */
40
49
  export function resolveToken() {
41
50
  const envToken = process.env["ZENORM_API_TOKEN"];
@@ -48,3 +57,81 @@ export function resolveToken() {
48
57
  }
49
58
  return null;
50
59
  }
60
+ /** True when the stored access token is expired (within the skew window). */
61
+ function isExpired(creds) {
62
+ const expiresAtMs = Date.parse(creds.expiresAt);
63
+ // An unparseable expiry is treated as expired so we refresh rather than send
64
+ // a token we can't reason about.
65
+ if (Number.isNaN(expiresAtMs))
66
+ return true;
67
+ return expiresAtMs - EXPIRY_SKEW_MS <= Date.now();
68
+ }
69
+ /**
70
+ * Resolve a usable access token, refreshing it first when the stored one is
71
+ * expired (or about to be). Rotated credentials are persisted.
72
+ *
73
+ * Priority: ZENORM_API_TOKEN env var (used as-is, never refreshed) > stored
74
+ * credentials (refreshed on demand). Returns null when there is nothing to
75
+ * resolve, leaving the caller to fall back to dev-bypass headers or error.
76
+ *
77
+ * A failed refresh (revoked/expired refresh token) throws; the caller surfaces
78
+ * it as "run `zenorm login`".
79
+ */
80
+ export async function resolveValidToken() {
81
+ const envToken = process.env["ZENORM_API_TOKEN"];
82
+ if (envToken) {
83
+ return envToken;
84
+ }
85
+ const creds = loadCredentials();
86
+ if (!creds) {
87
+ return null;
88
+ }
89
+ if (!creds.refreshToken || !isExpired(creds)) {
90
+ return creds.accessToken;
91
+ }
92
+ log.debug("Access token expired; refreshing via refresh_token grant");
93
+ const refreshed = await refreshAccessToken(creds.refreshToken);
94
+ const updated = {
95
+ ...creds,
96
+ accessToken: refreshed.accessToken,
97
+ refreshToken: refreshed.refreshToken,
98
+ expiresAt: new Date(Date.now() + refreshed.expiresIn * 1000).toISOString(),
99
+ };
100
+ saveCredentials(updated);
101
+ log.debug("Access token refreshed", { expiresAt: updated.expiresAt });
102
+ return refreshed.accessToken;
103
+ }
104
+ /**
105
+ * True when a refresh-token grant is possible: stored credentials with a
106
+ * refresh token exist and no explicit ZENORM_API_TOKEN override is in play
107
+ * (an env token is opaque to us and cannot be refreshed).
108
+ */
109
+ export function hasRefreshableCredentials() {
110
+ if (process.env["ZENORM_API_TOKEN"])
111
+ return false;
112
+ const creds = loadCredentials();
113
+ return Boolean(creds?.refreshToken);
114
+ }
115
+ /**
116
+ * Force a refresh regardless of expiry — used after a 401, where the server
117
+ * rejected a token that may have been revoked before its stated expiry.
118
+ * Persists rotated credentials and returns the new access token.
119
+ *
120
+ * Throws if there is nothing to refresh or the refresh grant fails.
121
+ */
122
+ export async function forceRefreshToken() {
123
+ const creds = loadCredentials();
124
+ if (!creds?.refreshToken) {
125
+ throw new Error("No refresh token available");
126
+ }
127
+ log.debug("Forcing token refresh after 401");
128
+ const refreshed = await refreshAccessToken(creds.refreshToken);
129
+ const updated = {
130
+ ...creds,
131
+ accessToken: refreshed.accessToken,
132
+ refreshToken: refreshed.refreshToken,
133
+ expiresAt: new Date(Date.now() + refreshed.expiresIn * 1000).toISOString(),
134
+ };
135
+ saveCredentials(updated);
136
+ return refreshed.accessToken;
137
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"whoami.d.ts","sourceRoot":"","sources":["../../src/commands/whoami.ts"],"names":[],"mappings":"AAIA,wBAAsB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAkBlE"}
1
+ {"version":3,"file":"whoami.d.ts","sourceRoot":"","sources":["../../src/commands/whoami.ts"],"names":[],"mappings":"AAIA,wBAAsB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAoBlE"}
@@ -2,9 +2,11 @@ import { loadCredentials, resolveToken } from "../auth/store.js";
2
2
  import { ZenormClient } from "../api/client.js";
3
3
  import { log } from "../util/logger.js";
4
4
  export async function whoamiCommand(_argv) {
5
- const token = resolveToken();
6
- const client = new ZenormClient({ token: token ?? undefined });
5
+ // Let the client resolve (and refresh if needed) the stored token itself —
6
+ // passing an explicit token here would bypass expiry handling.
7
+ const client = new ZenormClient();
7
8
  const ctx = await client.getAuthContext();
9
+ const token = resolveToken();
8
10
  log.info(`User: ${ctx.userId}`);
9
11
  log.info(`Org: ${ctx.orgId}`);
10
12
  log.info(`Admin: ${ctx.isAdmin ? "yes" : "no"}`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ze-norm/cli",
3
3
  "private": false,
4
- "version": "0.11.2",
4
+ "version": "0.11.3",
5
5
  "license": "SEE LICENSE IN README.md",
6
6
  "type": "module",
7
7
  "repository": {