@factorialco/fcode-cli 1.4.0 → 2.0.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/README.md CHANGED
@@ -20,7 +20,7 @@ $ npm install -g @factorialco/fcode-cli
20
20
  $ fcode COMMAND
21
21
  running command...
22
22
  $ fcode (--version)
23
- @factorialco/fcode-cli/1.4.0 linux-x64 node-v22.22.3
23
+ @factorialco/fcode-cli/2.0.0 linux-x64 node-v22.22.3
24
24
  $ fcode --help [COMMAND]
25
25
  USAGE
26
26
  $ fcode COMMAND
@@ -828,7 +828,7 @@ EXAMPLES
828
828
 
829
829
  ## `fcode team:push`
830
830
 
831
- Update cloud team settings (timezone, error handler, inheritance) from team.json
831
+ Update cloud team settings (timezone, error handler, inheritance, webhook auth) from team.json
832
832
 
833
833
  ```
834
834
  USAGE
@@ -838,7 +838,7 @@ FLAGS
838
838
  -f, --force Force command
839
839
 
840
840
  DESCRIPTION
841
- Update cloud team settings (timezone, error handler, inheritance) from team.json
841
+ Update cloud team settings (timezone, error handler, inheritance, webhook auth) from team.json
842
842
 
843
843
  EXAMPLES
844
844
  $ fcode team:push
@@ -11,12 +11,14 @@ export default class Http extends Command {
11
11
  };
12
12
  run(): Promise<void>;
13
13
  /**
14
- * Enforces the process's configured webhook basic auth, simulating the cloud
15
- * webhook endpoint. The username/password come from processes/<slug>/metadata.json;
16
- * an env-var placeholder (e.g. $WEBHOOK_PASSWORD) is resolved against the workspace
17
- * variables. Returns true when the request may proceed, false after writing a 401.
14
+ * Enforces the webhook's configured auth, simulating the cloud webhook endpoint: the effective
15
+ * configuration names a variable, whose value is looked up in the workspace variables and compared
16
+ * against the configured header. A missing or malformed header, an undefined variable, a mismatch,
17
+ * or a webhook inheriting a workspace configuration that team.json does not have, is rejected.
18
+ * Returns true when the request may proceed, false after writing a 403.
18
19
  */
19
20
  private _checkWebhookAuth;
21
+ private _forbid;
20
22
  private _handleProcessExecution;
21
23
  private _sendResponse;
22
24
  }
@@ -7,6 +7,9 @@ const process_runner_1 = require("../services/process-runner");
7
7
  const process_1 = require("../repositories/process");
8
8
  const variable_1 = require("../repositories/variable");
9
9
  const workspace_1 = require("../config/workspace");
10
+ const team_1 = require("../repositories/team");
11
+ const webhook_auth_1 = require("../services/webhook-auth");
12
+ const types_1 = require("../core/types");
10
13
  class Http extends core_1.Command {
11
14
  async run() {
12
15
  const { flags: { port, logLevel, 'auth-user': authUser, 'auth-password': authPassword, jsonLogs }, } = await this.parse(Http);
@@ -16,11 +19,17 @@ class Http extends core_1.Command {
16
19
  : null;
17
20
  const { slug: teamSlug } = await new workspace_1.default().getTeamOrigin();
18
21
  const processRepository = new process_1.default();
22
+ const teamRepository = new team_1.default();
23
+ if (useBasicAuth) {
24
+ this.warn(`--auth-user/--auth-password protect the whole server through the ${types_1.DEFAULT_WEBHOOK_AUTH_HEADER} header, ` +
25
+ `so a webhook expecting its credential there cannot be exercised at the same time.`);
26
+ }
19
27
  const handler = async (req, res) => {
20
28
  // CORS headers
21
29
  res.setHeader('Access-Control-Allow-Origin', '*');
22
30
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
23
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Fcode-Async');
31
+ // Reflected rather than fixed, so a webhook using a custom auth header passes the preflight.
32
+ res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers'] ?? 'Content-Type, Authorization, Fcode-Async');
24
33
  if (req.method === 'OPTIONS') {
25
34
  res.writeHead(204);
26
35
  res.end();
@@ -77,7 +86,7 @@ class Http extends core_1.Command {
77
86
  res.end(JSON.stringify({ success: false, error: `Process slug required in path (e.g. /api/${teamSlug}/webhooks/my-process)` }));
78
87
  return;
79
88
  }
80
- if (!(await this._checkWebhookAuth(req, res, processRepository, slug))) {
89
+ if (!(await this._checkWebhookAuth(req, res, processRepository, teamRepository, slug))) {
81
90
  return;
82
91
  }
83
92
  await this._handleProcessExecution(req, res, slug, urlObj, logLevel, jsonLogs);
@@ -110,12 +119,13 @@ class Http extends core_1.Command {
110
119
  await new Promise(() => { });
111
120
  }
112
121
  /**
113
- * Enforces the process's configured webhook basic auth, simulating the cloud
114
- * webhook endpoint. The username/password come from processes/<slug>/metadata.json;
115
- * an env-var placeholder (e.g. $WEBHOOK_PASSWORD) is resolved against the workspace
116
- * variables. Returns true when the request may proceed, false after writing a 401.
122
+ * Enforces the webhook's configured auth, simulating the cloud webhook endpoint: the effective
123
+ * configuration names a variable, whose value is looked up in the workspace variables and compared
124
+ * against the configured header. A missing or malformed header, an undefined variable, a mismatch,
125
+ * or a webhook inheriting a workspace configuration that team.json does not have, is rejected.
126
+ * Returns true when the request may proceed, false after writing a 403.
117
127
  */
118
- async _checkWebhookAuth(req, res, processRepository, slug) {
128
+ async _checkWebhookAuth(req, res, processRepository, teamRepository, slug) {
119
129
  let webhook;
120
130
  try {
121
131
  webhook = (await processRepository.find(slug)).webhook;
@@ -124,28 +134,34 @@ class Http extends core_1.Command {
124
134
  // Unknown process: let the execution path report the 404.
125
135
  return true;
126
136
  }
127
- if (!webhook || (!webhook.username && !webhook.password)) {
128
- return true;
137
+ // Read per request, like the process metadata above, so editing team.json while the server
138
+ // runs takes effect without a restart.
139
+ const teamWebhookAuth = (await teamRepository.find()).webhookAuth;
140
+ let effectiveAuth;
141
+ try {
142
+ effectiveAuth = (0, webhook_auth_1.resolveEffectiveAuth)(webhook, teamWebhookAuth);
129
143
  }
130
- const variables = await new variable_1.default().findAllForExecution();
131
- const values = new Map(variables.map((variable) => [variable.key, variable.value]));
132
- const resolvePlaceholder = (value) => {
133
- if (!value)
134
- return '';
135
- const match = value.match(/^\$\{?([A-Za-z_]\w*)\}?$/);
136
- return match ? values.get(match[1]) ?? value : value;
137
- };
138
- const expected = Buffer.from(`${resolvePlaceholder(webhook.username)}:${resolvePlaceholder(webhook.password)}`).toString('base64');
139
- const authHeader = req.headers['authorization'] ?? '';
140
- const match = authHeader.match(/^Basic (.+)$/);
141
- if (!match || match[1] !== expected) {
142
- res.setHeader('WWW-Authenticate', 'Basic realm="Factorial Code Webhook"');
143
- res.writeHead(401, { 'Content-Type': 'application/json' });
144
- res.end(JSON.stringify({ success: false, error: 'Unauthorized' }));
144
+ catch (error) {
145
+ // Fails closed, and says why: otherwise this reads as a wrong token rather than a local
146
+ // configuration the developer still has to complete.
147
+ this.warn(`Rejecting the ${slug} webhook: ${error.message}`);
148
+ this._forbid(res);
149
+ return false;
150
+ }
151
+ const variables = effectiveAuth === null ? [] : await new variable_1.default().findAllForExecution();
152
+ const variableValue = variables.find((variable) => variable.key === effectiveAuth?.variableKey)?.value;
153
+ const result = (0, webhook_auth_1.checkWebhookAuth)(effectiveAuth, req.headers, variableValue);
154
+ if (!result.authorized) {
155
+ this.warn(`Rejecting the ${slug} webhook: ${result.reason}`);
156
+ this._forbid(res);
145
157
  return false;
146
158
  }
147
159
  return true;
148
160
  }
161
+ _forbid(res) {
162
+ res.writeHead(403, { 'Content-Type': 'application/json' });
163
+ res.end(JSON.stringify({ success: false, error: 'Forbidden' }));
164
+ }
149
165
  async _handleProcessExecution(req, res, slug, urlObj, logLevel, jsonLogs) {
150
166
  if (req.method !== 'GET' && req.method !== 'POST') {
151
167
  res.writeHead(405, { 'Content-Type': 'application/json' });
@@ -30,12 +30,17 @@ class TeamPushCommand extends api_client_command_1.default {
30
30
  const processId = await this._teamRepository.resolveSlugToId(team.errorHandlerConfig.processSlug);
31
31
  update.executionErrorHandlerConfig = { processId, tag: team.errorHandlerConfig.tag ?? null };
32
32
  }
33
+ // Sent unconditionally, null arm included: team.json is the source of truth, so removing
34
+ // webhookAuth from it must clear the cloud configuration rather than leave it dangling.
35
+ update.webhookAuth = team.webhookAuth
36
+ ? { headerName: team.webhookAuth.headerName ?? null, variableKey: team.webhookAuth.variableKey }
37
+ : { variableKey: null };
33
38
  await this._fcodeGraphQLClient.pushTeam(update);
34
39
  await this.workspaceConfig.setTeamStoredHash(team.contentHash);
35
40
  cli_1.default.log(color_1.default.green('✅ team settings pushed'));
36
41
  }
37
42
  }
38
- TeamPushCommand.description = 'Update cloud team settings (timezone, error handler, inheritance) from team.json';
43
+ TeamPushCommand.description = 'Update cloud team settings (timezone, error handler, inheritance, webhook auth) from team.json';
39
44
  TeamPushCommand.examples = ['$ fcode team:push'];
40
45
  TeamPushCommand.flags = types_1.ForceFlag;
41
46
  exports.default = TeamPushCommand;
@@ -10,6 +10,7 @@ export type Credentials = {
10
10
  export interface Token {
11
11
  access_token: string;
12
12
  refresh_token: string;
13
+ expires_at?: number;
13
14
  }
14
15
  export interface UserInfo {
15
16
  groups: string[];
@@ -98,18 +99,33 @@ export type MergedResource = MergedResourceRecord & {
98
99
  };
99
100
  export type ProgrammingLanguage = 'JAVASCRIPT' | 'PYTHON';
100
101
  export declare const programmingLanguageDisplayName: (programmingLanguage: ProgrammingLanguage) => string;
102
+ export type WebhookAuthMode = 'NONE' | 'TEAM' | 'CUSTOM';
103
+ export declare const DEFAULT_WEBHOOK_AUTH_HEADER = "Authorization";
104
+ export interface WebhookAuth {
105
+ /**
106
+ * Header the caller must send the credential in. Omitted when it is the default
107
+ * `Authorization`, whose value must be `Bearer <token>`; any other header takes the raw value.
108
+ */
109
+ headerName?: string;
110
+ /**
111
+ * Key of the team variable holding the expected value. Only the name travels, never the
112
+ * value, and the variable does not need to exist yet.
113
+ */
114
+ variableKey: string;
115
+ }
101
116
  export interface ProcessWebhook {
102
117
  enabled: boolean;
103
- username?: string;
104
- password?: string;
118
+ /** Omitted when the webhook is public, so an unprotected webhook stays minimal on disk. */
119
+ authMode?: WebhookAuthMode;
120
+ /** The webhook's own configuration, only meaningful with the CUSTOM mode. */
121
+ auth?: WebhookAuth;
105
122
  }
106
- export type AppRole = 'INSTALL' | 'SETTINGS' | 'USER_FACING_FORM' | 'NONE';
123
+ export type AppRole = 'INSTALL' | 'SETTINGS' | 'USER_FACING_FORM' | 'UNINSTALL' | 'NONE';
124
+ export type FormAuthMode = 'NONE' | 'FACTORIAL';
107
125
  export interface ProcessForm {
108
126
  enabled: boolean;
109
127
  appRole?: AppRole;
110
- }
111
- export interface ProcessVisibility {
112
- isPublic: boolean;
128
+ authMode?: FormAuthMode;
113
129
  }
114
130
  export interface ProcessMetadataFields {
115
131
  name: string;
@@ -117,7 +133,6 @@ export interface ProcessMetadataFields {
117
133
  tags: string[];
118
134
  webhook?: ProcessWebhook;
119
135
  form?: ProcessForm;
120
- visibility?: ProcessVisibility;
121
136
  }
122
137
  export declare class Process extends Resource {
123
138
  id: string;
@@ -128,7 +143,6 @@ export declare class Process extends Resource {
128
143
  tags: string[];
129
144
  webhook?: ProcessWebhook;
130
145
  form?: ProcessForm;
131
- visibility?: ProcessVisibility;
132
146
  script: {
133
147
  parametersSchema: any;
134
148
  programmingLanguage: ProgrammingLanguage;
@@ -213,7 +227,8 @@ export declare class Team extends Resource {
213
227
  zoneId?: string;
214
228
  errorHandlerConfig?: ErrorHandlerConfig;
215
229
  parentTeamSlugs: string[];
216
- constructor(slug: string, parentTeamSlugs: string[], zoneId?: string, errorHandlerConfig?: ErrorHandlerConfig);
230
+ webhookAuth?: WebhookAuth;
231
+ constructor(slug: string, parentTeamSlugs: string[], zoneId?: string, errorHandlerConfig?: ErrorHandlerConfig, webhookAuth?: WebhookAuth);
217
232
  get contentHash(): string;
218
233
  }
219
234
  export interface RawErrorHandlerConfig {
@@ -225,11 +240,16 @@ export interface RawTeamSettings {
225
240
  zoneId?: string;
226
241
  errorHandlerConfig?: RawErrorHandlerConfig | null;
227
242
  parentTeamSlugs: string[];
243
+ webhookAuth?: WebhookAuth | null;
228
244
  }
229
245
  export interface TeamUpdateInput {
230
246
  zoneId?: string;
231
247
  parentTeamSlugs?: string[];
232
248
  executionErrorHandlerConfig?: RawErrorHandlerConfig | null;
249
+ webhookAuth?: {
250
+ headerName?: string | null;
251
+ variableKey?: string | null;
252
+ } | null;
233
253
  }
234
254
  export type ResourceMetadata = {
235
255
  [key: string]: any;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ServiceAccount = exports.Dependency = exports.DependenciesFlags = exports.TeamNameArg = exports.ResourceSlugArg = exports.ForceFlag = exports.ScriptLibraryNotFound = exports.ProcessNotFound = exports.Team = exports.ProgrammingLanguageManifest = exports.Variable = exports.VersionedScriptLibrary = exports.ScriptLibrary = exports.VersionedProcess = exports.Process = exports.programmingLanguageDisplayName = exports.ResourceStatus = exports.OnConflictResolution = exports.Resource = void 0;
3
+ exports.ServiceAccount = exports.Dependency = exports.DependenciesFlags = exports.TeamNameArg = exports.ResourceSlugArg = exports.ForceFlag = exports.ScriptLibraryNotFound = exports.ProcessNotFound = exports.Team = exports.ProgrammingLanguageManifest = exports.Variable = exports.VersionedScriptLibrary = exports.ScriptLibrary = exports.VersionedProcess = exports.Process = exports.DEFAULT_WEBHOOK_AUTH_HEADER = exports.programmingLanguageDisplayName = exports.ResourceStatus = exports.OnConflictResolution = exports.Resource = void 0;
4
4
  const core_1 = require("@oclif/core");
5
5
  const crypto = require("node:crypto");
6
6
  const canonical_hash_1 = require("../utils/canonical-hash");
@@ -37,6 +37,8 @@ const programmingLanguageDisplayName = (programmingLanguage) => {
37
37
  }
38
38
  };
39
39
  exports.programmingLanguageDisplayName = programmingLanguageDisplayName;
40
+ // The header assumed when none is configured; its value carries a Bearer scheme.
41
+ exports.DEFAULT_WEBHOOK_AUTH_HEADER = 'Authorization';
40
42
  class Process extends Resource {
41
43
  constructor(id, slug, script, readme, manifest, settings, metadata, resolvingTeamSlug) {
42
44
  super();
@@ -47,7 +49,6 @@ class Process extends Resource {
47
49
  this.tags = metadata.tags;
48
50
  this.webhook = metadata.webhook;
49
51
  this.form = metadata.form;
50
- this.visibility = metadata.visibility;
51
52
  this.script = script;
52
53
  this.readme = readme;
53
54
  this.manifest = settings.dependencies.scopedToProcess && manifest !== null ? manifest : { dependencies: [] };
@@ -64,7 +65,6 @@ class Process extends Resource {
64
65
  tags: this.tags || [],
65
66
  webhook: this.webhook || null,
66
67
  form: this.form || null,
67
- visibility: this.visibility || null,
68
68
  manifestDependencies: this.manifest.dependencies,
69
69
  dependenciesScopedToProcess: this.settings.dependencies.scopedToProcess,
70
70
  });
@@ -165,19 +165,27 @@ class ProgrammingLanguageManifest extends Resource {
165
165
  }
166
166
  exports.ProgrammingLanguageManifest = ProgrammingLanguageManifest;
167
167
  class Team extends Resource {
168
- constructor(slug, parentTeamSlugs, zoneId, errorHandlerConfig) {
168
+ constructor(slug, parentTeamSlugs, zoneId, errorHandlerConfig, webhookAuth) {
169
169
  super();
170
170
  this.slug = slug;
171
171
  this.parentTeamSlugs = parentTeamSlugs;
172
172
  this.zoneId = zoneId;
173
173
  this.errorHandlerConfig = errorHandlerConfig;
174
+ this.webhookAuth = webhookAuth;
174
175
  }
175
176
  get contentHash() {
176
- return (0, canonical_hash_1.canonicalJsonHash)({
177
+ const payload = {
177
178
  zoneId: this.zoneId || null,
178
179
  errorHandlerConfig: this.errorHandlerConfig || null,
179
180
  parentTeamSlugs: [...(this.parentTeamSlugs || [])].sort(),
180
- });
181
+ };
182
+ // Inserted only when set, so every team.json that predates webhook auth keeps the
183
+ // hash already stored in .fcode and does not report a phantom conflict. A key present
184
+ // with an undefined value would not do: canonicalStringify writes that as null.
185
+ if (this.webhookAuth) {
186
+ payload.webhookAuth = this.webhookAuth;
187
+ }
188
+ return (0, canonical_hash_1.canonicalJsonHash)(payload);
181
189
  }
182
190
  }
183
191
  exports.Team = Team;
@@ -136,7 +136,6 @@ class ProcessRepository extends resource_1.default {
136
136
  tags: Array.isArray(raw.tags) ? raw.tags : [],
137
137
  webhook: raw.webhook,
138
138
  form: raw.form,
139
- visibility: raw.visibility,
140
139
  };
141
140
  }
142
141
  catch (error) {
@@ -157,9 +156,6 @@ class ProcessRepository extends resource_1.default {
157
156
  if (process.form !== undefined) {
158
157
  metadata.form = process.form;
159
158
  }
160
- if (process.visibility !== undefined) {
161
- metadata.visibility = process.visibility;
162
- }
163
159
  return metadata;
164
160
  }
165
161
  async findParameters(processSlug) {
@@ -1,7 +1,7 @@
1
1
  import { RawTeamSettings, Team } from '../core/types';
2
2
  import WorkspaceConfig from '../config/workspace';
3
3
  /**
4
- * Reads/writes the singleton team.json (timezone, error handler, inheritance) at the
4
+ * Reads/writes the singleton team.json (timezone, error handler, inheritance, webhook auth) at the
5
5
  * workspace root. Unlike collection resources this is not a ResourceRepositoryBase:
6
6
  * there is exactly one team, so there is no slug-keyed collection to manage.
7
7
  *
@@ -19,5 +19,7 @@ export default class TeamRepository {
19
19
  resolveSlugToId(processSlug: string): Promise<string>;
20
20
  resolveIdToSlug(processId: string): Promise<string>;
21
21
  private parseErrorHandler;
22
+ /** A configuration without a variable key is meaningless, so it is read as absent. */
23
+ private parseWebhookAuth;
22
24
  private toFile;
23
25
  }
@@ -6,7 +6,7 @@ const workspace_1 = require("../config/workspace");
6
6
  const file_utils_1 = require("../utils/file-utils");
7
7
  const TEAM_FILENAME = 'team.json';
8
8
  /**
9
- * Reads/writes the singleton team.json (timezone, error handler, inheritance) at the
9
+ * Reads/writes the singleton team.json (timezone, error handler, inheritance, webhook auth) at the
10
10
  * workspace root. Unlike collection resources this is not a ResourceRepositoryBase:
11
11
  * there is exactly one team, so there is no slug-keyed collection to manage.
12
12
  *
@@ -29,7 +29,7 @@ class TeamRepository {
29
29
  }
30
30
  catch { }
31
31
  const parentTeamSlugs = Array.isArray(raw.parentTeamSlugs) ? raw.parentTeamSlugs : [];
32
- return new types_1.Team(slug, parentTeamSlugs, raw.zoneId, this.parseErrorHandler(raw.errorHandlerConfig));
32
+ return new types_1.Team(slug, parentTeamSlugs, raw.zoneId, this.parseErrorHandler(raw.errorHandlerConfig), this.parseWebhookAuth(raw.webhookAuth));
33
33
  }
34
34
  save(team) {
35
35
  (0, file_utils_1.outputFileSyncIfChanged)(TEAM_FILENAME, JSON.stringify(this.toFile(team), null, 2));
@@ -43,7 +43,7 @@ class TeamRepository {
43
43
  tag: raw.errorHandlerConfig.tag ?? null,
44
44
  };
45
45
  }
46
- return new types_1.Team(raw.slug, raw.parentTeamSlugs ?? [], raw.zoneId, errorHandlerConfig);
46
+ return new types_1.Team(raw.slug, raw.parentTeamSlugs ?? [], raw.zoneId, errorHandlerConfig, this.parseWebhookAuth(raw.webhookAuth));
47
47
  }
48
48
  async resolveSlugToId(processSlug) {
49
49
  const { processes } = await this.workspaceConfig.getActiveTeam();
@@ -60,6 +60,18 @@ class TeamRepository {
60
60
  }
61
61
  return { processSlug: raw.processSlug, tag: raw.tag ?? null };
62
62
  }
63
+ /** A configuration without a variable key is meaningless, so it is read as absent. */
64
+ parseWebhookAuth(raw) {
65
+ if (!raw || typeof raw.variableKey !== 'string' || raw.variableKey.length === 0) {
66
+ return undefined;
67
+ }
68
+ const webhookAuth = { variableKey: raw.variableKey };
69
+ // The default header is left out so a pull writes the minimal form.
70
+ if (typeof raw.headerName === 'string' && raw.headerName.length > 0 && raw.headerName !== types_1.DEFAULT_WEBHOOK_AUTH_HEADER) {
71
+ webhookAuth.headerName = raw.headerName;
72
+ }
73
+ return webhookAuth;
74
+ }
63
75
  toFile(team) {
64
76
  const file = { parentTeamSlugs: team.parentTeamSlugs ?? [] };
65
77
  if (team.zoneId !== undefined) {
@@ -68,6 +80,10 @@ class TeamRepository {
68
80
  if (team.errorHandlerConfig !== undefined) {
69
81
  file.errorHandlerConfig = team.errorHandlerConfig;
70
82
  }
83
+ // Last, so adding webhook auth to an existing team.json is a purely additive diff.
84
+ if (team.webhookAuth !== undefined) {
85
+ file.webhookAuth = team.webhookAuth;
86
+ }
71
87
  return file;
72
88
  }
73
89
  }
@@ -6,6 +6,9 @@ declare const AuthService: {
6
6
  getUserInfo(authUrl: string, token: Token): Promise<UserInfo>;
7
7
  globalConfig: GlobalConfig;
8
8
  isAuthenticated(url: string): Promise<boolean>;
9
+ isTokenExpiring(token: Token): boolean;
10
+ refreshToken(authUrl: string, refreshToken: string): Promise<Token>;
11
+ tryRefresh(url: string, { authUrl, token, userInfo }: AuthInfo): Promise<boolean>;
9
12
  login({ url, authUrl, email, password, clientId, clientSecret, accessToken }: Credentials): Promise<AuthInfo>;
10
13
  logout(url: string): Promise<void>;
11
14
  };
@@ -7,6 +7,15 @@ const global_1 = require("../config/global");
7
7
  const color_1 = require("@oclif/color");
8
8
  const AUTH_URL_PATH = '/protocol/openid-connect';
9
9
  const AUTH_CLIENT_ID = 'cli';
10
+ // Refresh this long before the access token actually expires, so a token
11
+ // snapshotted at command init doesn't die mid-command.
12
+ const TOKEN_EXPIRY_MARGIN_SECONDS = 300;
13
+ function withExpiresAt(token) {
14
+ if (typeof token.expires_in === 'number') {
15
+ return { ...token, expires_at: Math.floor(Date.now() / 1000) + token.expires_in };
16
+ }
17
+ return token;
18
+ }
10
19
  const AuthService = {
11
20
  clientId: process.env.FCODE_CLIENT_ID || AUTH_CLIENT_ID,
12
21
  async getToken({ authUrl, email, password, clientId, clientSecret }) {
@@ -44,7 +53,7 @@ const AuthService = {
44
53
  });
45
54
  const token = await response.json();
46
55
  if (response.ok) {
47
- return token;
56
+ return withExpiresAt(token);
48
57
  }
49
58
  throw token.error_description;
50
59
  },
@@ -64,14 +73,21 @@ const AuthService = {
64
73
  globalConfig: new global_1.default(),
65
74
  async isAuthenticated(url) {
66
75
  try {
67
- const { authUrl, token } = await this.globalConfig.getAuthInfo(url);
76
+ const authInfo = await this.globalConfig.getAuthInfo(url);
77
+ const { authUrl, token } = authInfo;
68
78
  try {
79
+ if (this.isTokenExpiring(token)) {
80
+ throw new Error('Access token expired or about to expire');
81
+ }
69
82
  await this.getUserInfo(authUrl, token);
70
83
  }
71
84
  catch (error) {
72
- await this.globalConfig.clearAuthInfo(url);
73
- cli_1.default.log(color_1.default.yellow('🚪 Session expired, please login again'));
74
- throw error;
85
+ const refreshed = await this.tryRefresh(url, authInfo);
86
+ if (!refreshed) {
87
+ await this.globalConfig.clearAuthInfo(url);
88
+ cli_1.default.log(color_1.default.yellow('🚪 Session expired, please login again'));
89
+ throw error;
90
+ }
75
91
  }
76
92
  return true;
77
93
  }
@@ -79,6 +95,41 @@ const AuthService = {
79
95
  return false;
80
96
  }
81
97
  },
98
+ isTokenExpiring(token) {
99
+ return token.expires_at !== undefined && Date.now() / 1000 >= token.expires_at - TOKEN_EXPIRY_MARGIN_SECONDS;
100
+ },
101
+ async refreshToken(authUrl, refreshToken) {
102
+ const body = new URLSearchParams({
103
+ client_id: this.clientId,
104
+ grant_type: 'refresh_token',
105
+ refresh_token: refreshToken,
106
+ });
107
+ const response = await (0, node_fetch_1.default)(`${authUrl}${AUTH_URL_PATH}/token`, {
108
+ body: body.toString(),
109
+ headers: {
110
+ 'Content-Type': 'application/x-www-form-urlencoded',
111
+ },
112
+ method: 'POST',
113
+ });
114
+ const token = await response.json();
115
+ if (response.ok) {
116
+ return withExpiresAt(token);
117
+ }
118
+ throw token.error_description;
119
+ },
120
+ async tryRefresh(url, { authUrl, token, userInfo }) {
121
+ if (!token.refresh_token) {
122
+ return false;
123
+ }
124
+ try {
125
+ const refreshedToken = await this.refreshToken(authUrl, token.refresh_token);
126
+ await this.globalConfig.setAuthInfo(url, { authUrl, token: refreshedToken, userInfo });
127
+ return true;
128
+ }
129
+ catch {
130
+ return false;
131
+ }
132
+ },
82
133
  async login({ url, authUrl, email, password, clientId, clientSecret, accessToken }) {
83
134
  const token = accessToken
84
135
  ? { access_token: accessToken, refresh_token: '' }
@@ -32,8 +32,18 @@ declare class FcodeGraphQLClient {
32
32
  removeVariable(id: String | undefined): Promise<Process>;
33
33
  programmingLanguageManifest(programmingLanguage: ProgrammingLanguage): Promise<ProgrammingLanguageManifest>;
34
34
  private inheritedTeam;
35
+ /**
36
+ * The public mode and the default header name are left out, so a public webhook — or one
37
+ * using the default header — keeps the metadata.json it already had on disk.
38
+ */
35
39
  private mapWebhook;
36
40
  mapProcess({ id, slug, name, description, tags, script, readme, manifest, settings, triggers, resolvingTeamSlug }: any): Process;
41
+ /**
42
+ * GraphQL nests the webhook configuration under `settings`, while team.json and the REST API
43
+ * keep it flat. Mapping it explicitly is what keeps that mismatch from silently reading as
44
+ * undefined.
45
+ */
46
+ mapTeamSettings(team: any): RawTeamSettings;
37
47
  team(): Promise<RawTeamSettings>;
38
48
  pushTeam(update: TeamUpdateInput): Promise<RawTeamSettings>;
39
49
  mapProgrammingLanguageManifest(programmingLanguageManifest: any): ProgrammingLanguageManifest;