@alphafox/cli 0.1.3 → 0.1.5

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.
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Silent access-token renewal via refresh_token grant.
3
+ * Access tokens are short-lived (~10m); refresh tokens last ~30d (web ADR).
4
+ */
5
+ import type { ProfileConfig } from "../config/profiles";
6
+ import { type StoredTokens } from "../keychain/store";
7
+ /** Refresh when access token expires within this window. */
8
+ export declare const ACCESS_TOKEN_REFRESH_SKEW_MS = 60000;
9
+ export declare function accessTokenNeedsRefresh(tokens: StoredTokens, now?: number): boolean;
10
+ /**
11
+ * Exchange refresh_token for a new AT/RT pair and persist to keychain.
12
+ * Returns null if no tokens, no refresh token, or the AS rejects renewal.
13
+ */
14
+ export declare function refreshStoredTokens(profile: ProfileConfig, env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch, options?: {
15
+ readonly now?: number;
16
+ readonly force?: boolean;
17
+ }): Promise<StoredTokens | null>;
18
+ /** Test helper: clear in-flight map between cases. */
19
+ export declare function clearRefreshInflightForTests(): void;
@@ -0,0 +1,188 @@
1
+ "use strict";
2
+ /**
3
+ * Silent access-token renewal via refresh_token grant.
4
+ * Access tokens are short-lived (~10m); refresh tokens last ~30d (web ADR).
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.ACCESS_TOKEN_REFRESH_SKEW_MS = void 0;
8
+ exports.accessTokenNeedsRefresh = accessTokenNeedsRefresh;
9
+ exports.refreshStoredTokens = refreshStoredTokens;
10
+ exports.clearRefreshInflightForTests = clearRefreshInflightForTests;
11
+ const store_1 = require("../keychain/store");
12
+ /** Refresh when access token expires within this window. */
13
+ exports.ACCESS_TOKEN_REFRESH_SKEW_MS = 60_000;
14
+ /** In-flight refresh promises so concurrent API calls share one rotation. */
15
+ const inflightByProfile = new Map();
16
+ function accessTokenNeedsRefresh(tokens, now = Date.now()) {
17
+ if (!tokens.refreshToken?.trim()) {
18
+ return false;
19
+ }
20
+ return tokens.expiresAt <= now + exports.ACCESS_TOKEN_REFRESH_SKEW_MS;
21
+ }
22
+ /**
23
+ * Exchange refresh_token for a new AT/RT pair and persist to keychain.
24
+ * Returns null if no tokens, no refresh token, or the AS rejects renewal.
25
+ */
26
+ async function refreshStoredTokens(profile, env = process.env, fetchImpl = fetch, options = {}) {
27
+ const existing = (0, store_1.loadTokens)(profile.name, env);
28
+ if (!existing?.refreshToken?.trim()) {
29
+ return null;
30
+ }
31
+ if (!options.force &&
32
+ !accessTokenNeedsRefresh(existing, options.now ?? Date.now())) {
33
+ return existing;
34
+ }
35
+ const key = profile.name;
36
+ const pending = inflightByProfile.get(key);
37
+ if (pending) {
38
+ return pending;
39
+ }
40
+ const work = performRefresh(profile, existing, env, fetchImpl).finally(() => {
41
+ inflightByProfile.delete(key);
42
+ });
43
+ inflightByProfile.set(key, work);
44
+ return work;
45
+ }
46
+ async function performRefresh(profile, existing, env, fetchImpl) {
47
+ const origin = profile.apiBaseUrl.replace(/\/$/, "").replace(/\/api\/v1$/, "");
48
+ const url = `${origin}/api/auth/oauth/token`;
49
+ const body = {
50
+ grant_type: "refresh_token",
51
+ refresh_token: existing.refreshToken,
52
+ client_id: existing.clientId || profile.clientId,
53
+ };
54
+ let response;
55
+ try {
56
+ response = await fetchFollowingSameSiteRedirects(fetchImpl, url, {
57
+ method: "POST",
58
+ headers: {
59
+ Accept: "application/json",
60
+ "Content-Type": "application/json",
61
+ "X-Alphafox-Client": "alphafox-cli",
62
+ "X-Alphafox-Client-Version": env.ALPHAFOX_CLI_VERSION ?? "0.1.0",
63
+ },
64
+ body: JSON.stringify(body),
65
+ redirect: "manual",
66
+ });
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ if (response.status >= 400) {
72
+ return null;
73
+ }
74
+ let json;
75
+ try {
76
+ json = await response.json();
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ if (!json || typeof json !== "object") {
82
+ return null;
83
+ }
84
+ const o = json;
85
+ const access = typeof o.access_token === "string"
86
+ ? o.access_token
87
+ : typeof o.accessToken === "string"
88
+ ? o.accessToken
89
+ : null;
90
+ const refresh = typeof o.refresh_token === "string"
91
+ ? o.refresh_token
92
+ : typeof o.refreshToken === "string"
93
+ ? o.refreshToken
94
+ : null;
95
+ if (!access || !refresh) {
96
+ return null;
97
+ }
98
+ const expiresIn = typeof o.expires_in === "number"
99
+ ? o.expires_in
100
+ : typeof o.expiresIn === "number"
101
+ ? o.expiresIn
102
+ : 600;
103
+ const scopeRaw = typeof o.scope === "string"
104
+ ? o.scope
105
+ : existing.scopes.join(" ");
106
+ const next = {
107
+ accessToken: access,
108
+ refreshToken: refresh,
109
+ expiresAt: Date.now() + expiresIn * 1000,
110
+ environment: existing.environment || profile.name,
111
+ issuer: existing.issuer || profile.issuer,
112
+ audience: existing.audience || profile.audience,
113
+ clientId: existing.clientId || profile.clientId,
114
+ scopes: scopeRaw.split(/\s+/).filter(Boolean),
115
+ };
116
+ (0, store_1.saveTokens)(profile.name, next, env);
117
+ return next;
118
+ }
119
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
120
+ async function fetchFollowingSameSiteRedirects(fetchImpl, startUrl, init, maxHops = 5) {
121
+ let url = startUrl;
122
+ let method = (init.method ?? "GET").toUpperCase();
123
+ let body = init.body;
124
+ let response = await fetchImpl(url, {
125
+ ...init,
126
+ method,
127
+ body,
128
+ redirect: "manual",
129
+ });
130
+ for (let hop = 0; hop < maxHops && REDIRECT_STATUSES.has(response.status); hop++) {
131
+ const location = response.headers.get("location");
132
+ if (!location)
133
+ break;
134
+ const nextUrl = new URL(location, url).toString();
135
+ if (!sameAuthSite(originOf(url), originOf(nextUrl)))
136
+ break;
137
+ if (response.status === 303 ||
138
+ ((response.status === 301 || response.status === 302) &&
139
+ method !== "GET" &&
140
+ method !== "HEAD")) {
141
+ method = "GET";
142
+ body = undefined;
143
+ }
144
+ url = nextUrl;
145
+ response = await fetchImpl(url, {
146
+ ...init,
147
+ method,
148
+ body,
149
+ redirect: "manual",
150
+ });
151
+ }
152
+ return response;
153
+ }
154
+ function originOf(value) {
155
+ if (!value)
156
+ return null;
157
+ try {
158
+ if (value.startsWith("http://") || value.startsWith("https://")) {
159
+ return new URL(value).origin;
160
+ }
161
+ return null;
162
+ }
163
+ catch {
164
+ return null;
165
+ }
166
+ }
167
+ function sameAuthSite(a, b) {
168
+ if (!a || !b)
169
+ return false;
170
+ if (a === b)
171
+ return true;
172
+ try {
173
+ const ua = new URL(a);
174
+ const ub = new URL(b);
175
+ if (ua.protocol !== ub.protocol)
176
+ return false;
177
+ const ha = ua.hostname.replace(/^www\./i, "").toLowerCase();
178
+ const hb = ub.hostname.replace(/^www\./i, "").toLowerCase();
179
+ return ha === hb && ua.port === ub.port;
180
+ }
181
+ catch {
182
+ return false;
183
+ }
184
+ }
185
+ /** Test helper: clear in-flight map between cases. */
186
+ function clearRefreshInflightForTests() {
187
+ inflightByProfile.clear();
188
+ }
@@ -8,6 +8,8 @@ export interface ApiRequestOptions {
8
8
  readonly requestId?: string;
9
9
  readonly skipAuth?: boolean;
10
10
  readonly idempotencyKey?: string;
11
+ /** Internal: already attempted one silent refresh+retry for this call. */
12
+ readonly _refreshRetried?: boolean;
11
13
  }
12
14
  export interface ApiResponse {
13
15
  readonly status: number;
@@ -2,10 +2,12 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.apiRequest = apiRequest;
4
4
  const envelope_1 = require("../envelope");
5
+ const refresh_1 = require("../auth/refresh");
5
6
  const store_1 = require("../keychain/store");
6
7
  const allowlist_1 = require("../catalog/allowlist");
7
8
  async function apiRequest(options, env = process.env, fetchImpl = fetch) {
8
- const path = (0, allowlist_1.normalizeApiPath)(options.path);
9
+ // Keep query string for endpoints like /traders/performance?ids=...
10
+ const { path, query } = splitPathAndQuery(options.path);
9
11
  if ((0, allowlist_1.isInternalDisallowedPath)(path)) {
10
12
  throw Object.assign(new Error(`Path is internal and not allowed: ${path}`), {
11
13
  status: 403,
@@ -31,14 +33,14 @@ async function apiRequest(options, env = process.env, fetchImpl = fetch) {
31
33
  let url;
32
34
  if (path.startsWith("/api/v1")) {
33
35
  const origin = base.replace(/\/api\/v1$/, "");
34
- url = `${origin}${path}`;
36
+ url = `${origin}${path}${query}`;
35
37
  }
36
38
  else if (path.startsWith("/api/auth")) {
37
39
  const origin = base.replace(/\/api\/v1$/, "");
38
- url = `${origin}${path}`;
40
+ url = `${origin}${path}${query}`;
39
41
  }
40
42
  else {
41
- url = `${base}${path.startsWith("/") ? path : `/${path}`}`;
43
+ url = `${base}${path.startsWith("/") ? path : `/${path}`}${query}`;
42
44
  }
43
45
  const headers = {
44
46
  Accept: "application/json",
@@ -48,7 +50,14 @@ async function apiRequest(options, env = process.env, fetchImpl = fetch) {
48
50
  ...(options.headers ?? {}),
49
51
  };
50
52
  if (!options.skipAuth) {
51
- const tokens = (0, store_1.loadTokens)(options.profile.name, env);
53
+ let tokens = (0, store_1.loadTokens)(options.profile.name, env);
54
+ // Proactive refresh before the access token expires (or once already expired).
55
+ if (tokens && (0, refresh_1.accessTokenNeedsRefresh)(tokens)) {
56
+ const renewed = await (0, refresh_1.refreshStoredTokens)(options.profile, env, fetchImpl, { force: true });
57
+ if (renewed) {
58
+ tokens = renewed;
59
+ }
60
+ }
52
61
  if (tokens) {
53
62
  // Never send tokens to a different site than the profile audience.
54
63
  // Apex/www (and trailing host variants) of the same registrable domain
@@ -94,6 +103,19 @@ async function apiRequest(options, env = process.env, fetchImpl = fetch) {
94
103
  const responseRequestId = response.headers.get("x-request-id") ??
95
104
  response.headers.get("X-Request-Id") ??
96
105
  requestId;
106
+ // Reactive: one silent refresh+retry on 401 for authenticated product calls.
107
+ if (response.status === 401 &&
108
+ !options.skipAuth &&
109
+ !options._refreshRetried &&
110
+ !isOAuthAsPath) {
111
+ const tokens = (0, store_1.loadTokens)(options.profile.name, env);
112
+ if (tokens?.refreshToken?.trim()) {
113
+ const renewed = await (0, refresh_1.refreshStoredTokens)(options.profile, env, fetchImpl, { force: true });
114
+ if (renewed) {
115
+ return apiRequest({ ...options, requestId, _refreshRetried: true }, env, fetchImpl);
116
+ }
117
+ }
118
+ }
97
119
  return {
98
120
  status: response.status,
99
121
  headers: response.headers,
@@ -169,3 +191,15 @@ function sameAuthSite(a, b) {
169
191
  return false;
170
192
  }
171
193
  }
194
+ /** Split raw path so allowlist uses path-only while fetch keeps query. */
195
+ function splitPathAndQuery(raw) {
196
+ const trimmed = raw.trim();
197
+ const q = trimmed.indexOf("?");
198
+ if (q < 0) {
199
+ return { path: (0, allowlist_1.normalizeApiPath)(trimmed), query: "" };
200
+ }
201
+ return {
202
+ path: (0, allowlist_1.normalizeApiPath)(trimmed.slice(0, q)),
203
+ query: trimmed.slice(q),
204
+ };
205
+ }
@@ -38,10 +38,14 @@ function saveTokens(profile, tokens, env = process.env) {
38
38
  function loadTokens(profile, env = process.env) {
39
39
  // Controlled test injection — never document as prod automation.
40
40
  if (env.ALPHAFOX_TEST_ACCESS_TOKEN?.trim()) {
41
+ const expiresAtRaw = env.ALPHAFOX_TEST_EXPIRES_AT?.trim();
42
+ const expiresAt = expiresAtRaw
43
+ ? Number(expiresAtRaw)
44
+ : Date.now() + 3600_000;
41
45
  return {
42
46
  accessToken: env.ALPHAFOX_TEST_ACCESS_TOKEN.trim(),
43
47
  refreshToken: env.ALPHAFOX_TEST_REFRESH_TOKEN?.trim() ?? "",
44
- expiresAt: Date.now() + 3600_000,
48
+ expiresAt: Number.isFinite(expiresAt) ? expiresAt : Date.now() + 3600_000,
45
49
  environment: profile,
46
50
  issuer: env.ALPHAFOX_TEST_ISSUER ?? "",
47
51
  audience: env.ALPHAFOX_TEST_AUDIENCE ?? "",
package/dist/version.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export declare const CLI_NAME = "alphafox";
2
2
  export declare const CLI_PACKAGE = "@alphafox/cli";
3
- export declare const CLI_VERSION = "0.1.2";
3
+ export declare const CLI_VERSION = "0.1.5";
4
4
  export declare const CLI_CONTRACT_VERSION = "2026-08-11";
package/dist/version.js CHANGED
@@ -3,5 +3,5 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CLI_CONTRACT_VERSION = exports.CLI_VERSION = exports.CLI_PACKAGE = exports.CLI_NAME = void 0;
4
4
  exports.CLI_NAME = "alphafox";
5
5
  exports.CLI_PACKAGE = "@alphafox/cli";
6
- exports.CLI_VERSION = "0.1.2";
6
+ exports.CLI_VERSION = "0.1.5";
7
7
  exports.CLI_CONTRACT_VERSION = "2026-08-11";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alphafox/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Alphafox CLI \u2014 Agent/Human entry for the public Application API.",
5
5
  "type": "commonjs",
6
6
  "bin": {