@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.
@@ -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 };
@@ -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
- const config = await store.loadCredentials();
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 { context } from '../../api/context.js';
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 context();
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 { context } from '../../api/context.js';
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 context();
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
- await store.saveCredentials({ token: access_token, email: me.email });
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
- const config = await store.loadCredentials();
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "1.5.33",
3
+ "version": "1.7.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",