@faable/faable 1.5.33 → 1.7.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/api/FaableApi.js +7 -0
- package/dist/api/auth.js +24 -1
- package/dist/api/context.js +17 -2
- package/dist/api/session.js +55 -0
- package/dist/commands/deploy/index.js +2 -6
- package/dist/commands/link/index.js +2 -6
- package/dist/commands/login/index.js +35 -2
- package/dist/commands/whoami/index.js +3 -1
- package/package.json +1 -1
package/dist/api/FaableApi.js
CHANGED
|
@@ -33,6 +33,13 @@ class FaableApi {
|
|
|
33
33
|
const res = e.response;
|
|
34
34
|
const url = e.config?.url || "";
|
|
35
35
|
if (res) {
|
|
36
|
+
// Uniform handling for an expired/invalid session across every
|
|
37
|
+
// command, regardless of which endpoint returned the 401.
|
|
38
|
+
if (res.status === 401) {
|
|
39
|
+
const expired = new Error("Your Faable session has expired or is invalid. Run `faable login` to sign in again.", { cause: error });
|
|
40
|
+
expired.status = 401;
|
|
41
|
+
throw expired;
|
|
42
|
+
}
|
|
36
43
|
const serverMessage = res.data?.message || res.statusText || "Unknown Error";
|
|
37
44
|
const wrapped = new Error(`FaableApi ${url} ${res.status}: ${serverMessage}`, { cause: error });
|
|
38
45
|
// Surface the structured error contract (e.g. the repository-link
|
package/dist/api/auth.js
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
|
+
import os from 'os';
|
|
2
3
|
|
|
3
4
|
const api = axios.create({ baseURL: "https://faable.auth.faable.link" });
|
|
4
5
|
const CLIENT_ID = "c879023b-e34f-4b0c-a262-210e556bc2e4";
|
|
6
|
+
const PLATFORM_LABELS = {
|
|
7
|
+
darwin: "macOS",
|
|
8
|
+
win32: "Windows",
|
|
9
|
+
linux: "Linux",
|
|
10
|
+
};
|
|
11
|
+
// Human-friendly device name shown on the auth confirm page so the user can
|
|
12
|
+
// recognise which device they are authorising, e.g. "marcs-mbp (macOS)".
|
|
13
|
+
function deviceName() {
|
|
14
|
+
const platform = PLATFORM_LABELS[os.platform()] || os.platform();
|
|
15
|
+
return `${os.hostname()} (${platform})`;
|
|
16
|
+
}
|
|
5
17
|
async function getDeviceCode() {
|
|
6
18
|
const res = await api.post(`/oauth/device/code`, {
|
|
7
19
|
client_id: CLIENT_ID,
|
|
8
20
|
scope: "openid email profile offline_access",
|
|
21
|
+
device_name: deviceName(),
|
|
9
22
|
});
|
|
10
23
|
return res.data;
|
|
11
24
|
}
|
|
@@ -22,5 +35,15 @@ async function getMe(access_token) {
|
|
|
22
35
|
});
|
|
23
36
|
return res.data;
|
|
24
37
|
}
|
|
38
|
+
// Exchange a refresh token for a fresh access token (and a rotated refresh
|
|
39
|
+
// token). The CLI is a public client, so no client_secret is required.
|
|
40
|
+
async function refreshToken(refresh_token) {
|
|
41
|
+
const res = await api.post(`/oauth/token`, {
|
|
42
|
+
grant_type: "refresh_token",
|
|
43
|
+
client_id: CLIENT_ID,
|
|
44
|
+
refresh_token,
|
|
45
|
+
});
|
|
46
|
+
return res.data;
|
|
47
|
+
}
|
|
25
48
|
|
|
26
|
-
export { getDeviceCode, getDeviceToken, getMe };
|
|
49
|
+
export { getDeviceCode, getDeviceToken, getMe, refreshToken };
|
package/dist/api/context.js
CHANGED
|
@@ -4,6 +4,8 @@ import { getIDToken } from '@actions/core';
|
|
|
4
4
|
import { oidc_strategy } from './strategies/oidc.strategy.js';
|
|
5
5
|
import { bearer_strategy } from './strategies/bearer.strategy.js';
|
|
6
6
|
import { CredentialsStore } from '../lib/CredentialsStore.js';
|
|
7
|
+
import { loadLiveCredentials } from './session.js';
|
|
8
|
+
import { log } from '../log.js';
|
|
7
9
|
|
|
8
10
|
const context = async () => {
|
|
9
11
|
let api;
|
|
@@ -25,7 +27,8 @@ const context = async () => {
|
|
|
25
27
|
}
|
|
26
28
|
else {
|
|
27
29
|
const store = new CredentialsStore();
|
|
28
|
-
|
|
30
|
+
// Auto-refreshes an expired access token via the stored refresh_token.
|
|
31
|
+
const config = await loadLiveCredentials(store);
|
|
29
32
|
if (config) {
|
|
30
33
|
if (config.token) {
|
|
31
34
|
api = FaableApi.create({
|
|
@@ -47,5 +50,17 @@ const context = async () => {
|
|
|
47
50
|
appId,
|
|
48
51
|
};
|
|
49
52
|
};
|
|
53
|
+
// Resolve the API client for a command that needs an authenticated session.
|
|
54
|
+
// Exits with a uniform message when there is no session at all. An expired
|
|
55
|
+
// session that could not be refreshed still surfaces here as a working `api`
|
|
56
|
+
// whose first call returns 401 — FaableApi maps that to the same re-login hint.
|
|
57
|
+
const requireApi = async () => {
|
|
58
|
+
const ctx = await context();
|
|
59
|
+
if (!ctx.api) {
|
|
60
|
+
log.error("❌ Not logged in. Run 'faable login' first.");
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
return ctx;
|
|
64
|
+
};
|
|
50
65
|
|
|
51
|
-
export { context };
|
|
66
|
+
export { context, requireApi };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { CredentialsStore } from '../lib/CredentialsStore.js';
|
|
2
|
+
import { refreshToken } from './auth.js';
|
|
3
|
+
import { log } from '../log.js';
|
|
4
|
+
|
|
5
|
+
// True when a stored JWT access token is still valid (has a future `exp`).
|
|
6
|
+
// Anything we can't decode, or without `exp`, counts as NOT live — callers then
|
|
7
|
+
// refresh or ask the user to re-login instead of trusting a dead token.
|
|
8
|
+
const isTokenLive = (token) => {
|
|
9
|
+
try {
|
|
10
|
+
const [, payload] = token.split(".");
|
|
11
|
+
const claims = JSON.parse(Buffer.from(payload, "base64url").toString());
|
|
12
|
+
if (typeof claims.exp !== "number")
|
|
13
|
+
return false;
|
|
14
|
+
// 30s skew so a near-expired token is treated as dead and refreshed early.
|
|
15
|
+
return claims.exp * 1000 > Date.now() + 30_000;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
// Load local credentials, transparently refreshing an expired (or near-expired)
|
|
22
|
+
// access token using the stored refresh_token. The refreshed credentials are
|
|
23
|
+
// persisted so the next command reuses them. Returns the (possibly refreshed)
|
|
24
|
+
// config, or undefined when there are no local credentials.
|
|
25
|
+
//
|
|
26
|
+
// This is the single place the CLI resolves a local session, so every command
|
|
27
|
+
// that goes through it gets auto-refresh for free. If the refresh fails (e.g.
|
|
28
|
+
// the refresh token itself expired or was revoked) we return the stale config
|
|
29
|
+
// and let the downstream 401 surface the uniform "session expired" error.
|
|
30
|
+
const loadLiveCredentials = async (store = new CredentialsStore()) => {
|
|
31
|
+
const config = await store.loadCredentials();
|
|
32
|
+
if (!config)
|
|
33
|
+
return undefined;
|
|
34
|
+
const needsRefresh = !!config.token && !isTokenLive(config.token);
|
|
35
|
+
if (needsRefresh && config.refresh_token) {
|
|
36
|
+
try {
|
|
37
|
+
const refreshed = await refreshToken(config.refresh_token);
|
|
38
|
+
const next = {
|
|
39
|
+
...config,
|
|
40
|
+
token: refreshed.access_token,
|
|
41
|
+
refresh_token: refreshed.refresh_token ?? config.refresh_token,
|
|
42
|
+
};
|
|
43
|
+
await store.saveCredentials(next);
|
|
44
|
+
log.debug?.("Refreshed access token");
|
|
45
|
+
return next;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Refresh failed — keep the stale config; the API call's 401 (or
|
|
49
|
+
// requireApi) will tell the user to run `faable login` again.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return config;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export { isTokenLive, loadLiveCredentials };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { requireApi } from '../../api/context.js';
|
|
2
2
|
import { cmd } from '../../lib/cmd.js';
|
|
3
3
|
import { Configuration } from '../../lib/Configuration.js';
|
|
4
4
|
import { log } from '../../log.js';
|
|
@@ -27,12 +27,8 @@ const deploy = {
|
|
|
27
27
|
},
|
|
28
28
|
handler: async (args) => {
|
|
29
29
|
const workdir = args.workdir || process.cwd();
|
|
30
|
-
const ctx = await
|
|
30
|
+
const ctx = await requireApi();
|
|
31
31
|
const { api } = ctx;
|
|
32
|
-
if (!api) {
|
|
33
|
-
log.error("❌ Not logged in. Run 'faable login' first.");
|
|
34
|
-
process.exit(1);
|
|
35
|
-
}
|
|
36
32
|
// Resolve runtime
|
|
37
33
|
const { runtime } = await runtime_detection(workdir);
|
|
38
34
|
// app_id resolution (the user never has to look one up):
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { requireApi } from '../../api/context.js';
|
|
2
2
|
import prompts from 'prompts';
|
|
3
3
|
import { log } from '../../log.js';
|
|
4
4
|
import { cmd } from '../../lib/cmd.js';
|
|
@@ -60,11 +60,7 @@ const link = {
|
|
|
60
60
|
return;
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
|
-
const { api } = await
|
|
64
|
-
if (!api) {
|
|
65
|
-
log.error("❌ Not logged in. Run 'faable login' first.");
|
|
66
|
-
process.exit(1);
|
|
67
|
-
}
|
|
63
|
+
const { api } = await requireApi();
|
|
68
64
|
log.info("Checking local git repository...");
|
|
69
65
|
const gitUrl = await getGitRemoteUrl(workdir);
|
|
70
66
|
const apps = await api.list();
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { FaableApi } from '../../api/FaableApi.js';
|
|
2
2
|
import { getDeviceCode, getDeviceToken, getMe } from '../../api/auth.js';
|
|
3
|
+
import { isTokenLive } from '../../api/session.js';
|
|
3
4
|
import { CredentialsStore } from '../../lib/CredentialsStore.js';
|
|
4
5
|
import open from 'open';
|
|
5
6
|
import ora from 'ora';
|
|
7
|
+
import prompts from 'prompts';
|
|
6
8
|
import { log } from '../../log.js';
|
|
7
9
|
|
|
8
10
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -41,6 +43,31 @@ const login = {
|
|
|
41
43
|
handler: async (args) => {
|
|
42
44
|
const { apikey, token } = args;
|
|
43
45
|
const store = new CredentialsStore();
|
|
46
|
+
// Interactive flow only: if there's already a session, confirm before
|
|
47
|
+
// re-authenticating. Flag-based logins (--apikey/--token) are an explicit
|
|
48
|
+
// re-auth (and often scripted), so they skip the prompt and overwrite.
|
|
49
|
+
if (!apikey && !token) {
|
|
50
|
+
const existing = await store.loadCredentials();
|
|
51
|
+
// Only a still-valid bearer session (or an API key) counts as "logged
|
|
52
|
+
// in". An expired token is a dead session, so skip the prompt and go
|
|
53
|
+
// straight to re-login instead of misleadingly saying "already logged in".
|
|
54
|
+
const hasLiveSession = (!!existing?.token && isTokenLive(existing.token)) ||
|
|
55
|
+
!!existing?.apikey;
|
|
56
|
+
if (hasLiveSession) {
|
|
57
|
+
const who = existing?.email ? ` as ${existing.email}` : "";
|
|
58
|
+
const { proceed } = await prompts({
|
|
59
|
+
type: "confirm",
|
|
60
|
+
name: "proceed",
|
|
61
|
+
message: `You are already logged in${who}. Log out and log in again?`,
|
|
62
|
+
initial: false,
|
|
63
|
+
});
|
|
64
|
+
if (!proceed) {
|
|
65
|
+
log.info("Keeping current session. Run `faable logout` to log out.");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
await store.deleteCredentials();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
44
71
|
if (apikey) {
|
|
45
72
|
log.info("Logging in with API Key...");
|
|
46
73
|
const tempApi = FaableApi.create({ auth: { apikey }, authStrategy: (await import('../../api/strategies/apikey.strategy.js')).apikey_strategy });
|
|
@@ -119,13 +146,19 @@ const login = {
|
|
|
119
146
|
if (cancelled)
|
|
120
147
|
return;
|
|
121
148
|
try {
|
|
122
|
-
const { access_token } = await getDeviceToken(device_code);
|
|
149
|
+
const { access_token, refresh_token } = await getDeviceToken(device_code);
|
|
123
150
|
if (access_token) {
|
|
124
151
|
spinner.stop();
|
|
125
152
|
// Validate the freshly issued token against the Auth server (it
|
|
126
153
|
// issued the token). The deploy API has no /me route.
|
|
127
154
|
const me = await getMe(access_token);
|
|
128
|
-
|
|
155
|
+
// Persist the refresh_token so the session can be renewed silently
|
|
156
|
+
// when the access token expires (see api/session.ts).
|
|
157
|
+
await store.saveCredentials({
|
|
158
|
+
token: access_token,
|
|
159
|
+
refresh_token,
|
|
160
|
+
email: me.email,
|
|
161
|
+
});
|
|
129
162
|
log.info(`✅ Successfully logged in as ${me.email}`);
|
|
130
163
|
return;
|
|
131
164
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { log } from '../../log.js';
|
|
2
2
|
import { getMe } from '../../api/auth.js';
|
|
3
|
+
import { loadLiveCredentials } from '../../api/session.js';
|
|
3
4
|
import { CredentialsStore } from '../../lib/CredentialsStore.js';
|
|
4
5
|
|
|
5
6
|
const whoami = {
|
|
@@ -7,7 +8,8 @@ const whoami = {
|
|
|
7
8
|
describe: "Display the current logged in user",
|
|
8
9
|
handler: async () => {
|
|
9
10
|
const store = new CredentialsStore();
|
|
10
|
-
|
|
11
|
+
// Auto-refreshes an expired token via the stored refresh_token.
|
|
12
|
+
const config = await loadLiveCredentials(store);
|
|
11
13
|
// Bearer token from the environment (CI) or a local `faable login`.
|
|
12
14
|
const token = process.env.FAABLE_TOKEN || config?.token;
|
|
13
15
|
if (!token) {
|