@alphafox/cli 0.1.3 → 0.1.4

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,6 +2,7 @@
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) {
@@ -48,7 +49,14 @@ async function apiRequest(options, env = process.env, fetchImpl = fetch) {
48
49
  ...(options.headers ?? {}),
49
50
  };
50
51
  if (!options.skipAuth) {
51
- const tokens = (0, store_1.loadTokens)(options.profile.name, env);
52
+ let tokens = (0, store_1.loadTokens)(options.profile.name, env);
53
+ // Proactive refresh before the access token expires (or once already expired).
54
+ if (tokens && (0, refresh_1.accessTokenNeedsRefresh)(tokens)) {
55
+ const renewed = await (0, refresh_1.refreshStoredTokens)(options.profile, env, fetchImpl, { force: true });
56
+ if (renewed) {
57
+ tokens = renewed;
58
+ }
59
+ }
52
60
  if (tokens) {
53
61
  // Never send tokens to a different site than the profile audience.
54
62
  // Apex/www (and trailing host variants) of the same registrable domain
@@ -94,6 +102,19 @@ async function apiRequest(options, env = process.env, fetchImpl = fetch) {
94
102
  const responseRequestId = response.headers.get("x-request-id") ??
95
103
  response.headers.get("X-Request-Id") ??
96
104
  requestId;
105
+ // Reactive: one silent refresh+retry on 401 for authenticated product calls.
106
+ if (response.status === 401 &&
107
+ !options.skipAuth &&
108
+ !options._refreshRetried &&
109
+ !isOAuthAsPath) {
110
+ const tokens = (0, store_1.loadTokens)(options.profile.name, env);
111
+ if (tokens?.refreshToken?.trim()) {
112
+ const renewed = await (0, refresh_1.refreshStoredTokens)(options.profile, env, fetchImpl, { force: true });
113
+ if (renewed) {
114
+ return apiRequest({ ...options, requestId, _refreshRetried: true }, env, fetchImpl);
115
+ }
116
+ }
117
+ }
97
118
  return {
98
119
  status: response.status,
99
120
  headers: response.headers,
@@ -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.4";
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.4";
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.4",
4
4
  "description": "Alphafox CLI \u2014 Agent/Human entry for the public Application API.",
5
5
  "type": "commonjs",
6
6
  "bin": {