@factorialco/fcode-cli 1.5.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.5.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,15 +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
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;
128
+ authMode?: FormAuthMode;
110
129
  }
111
130
  export interface ProcessMetadataFields {
112
131
  name: string;
@@ -208,7 +227,8 @@ export declare class Team extends Resource {
208
227
  zoneId?: string;
209
228
  errorHandlerConfig?: ErrorHandlerConfig;
210
229
  parentTeamSlugs: string[];
211
- constructor(slug: string, parentTeamSlugs: string[], zoneId?: string, errorHandlerConfig?: ErrorHandlerConfig);
230
+ webhookAuth?: WebhookAuth;
231
+ constructor(slug: string, parentTeamSlugs: string[], zoneId?: string, errorHandlerConfig?: ErrorHandlerConfig, webhookAuth?: WebhookAuth);
212
232
  get contentHash(): string;
213
233
  }
214
234
  export interface RawErrorHandlerConfig {
@@ -220,11 +240,16 @@ export interface RawTeamSettings {
220
240
  zoneId?: string;
221
241
  errorHandlerConfig?: RawErrorHandlerConfig | null;
222
242
  parentTeamSlugs: string[];
243
+ webhookAuth?: WebhookAuth | null;
223
244
  }
224
245
  export interface TeamUpdateInput {
225
246
  zoneId?: string;
226
247
  parentTeamSlugs?: string[];
227
248
  executionErrorHandlerConfig?: RawErrorHandlerConfig | null;
249
+ webhookAuth?: {
250
+ headerName?: string | null;
251
+ variableKey?: string | null;
252
+ } | null;
228
253
  }
229
254
  export type ResourceMetadata = {
230
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();
@@ -163,19 +165,27 @@ class ProgrammingLanguageManifest extends Resource {
163
165
  }
164
166
  exports.ProgrammingLanguageManifest = ProgrammingLanguageManifest;
165
167
  class Team extends Resource {
166
- constructor(slug, parentTeamSlugs, zoneId, errorHandlerConfig) {
168
+ constructor(slug, parentTeamSlugs, zoneId, errorHandlerConfig, webhookAuth) {
167
169
  super();
168
170
  this.slug = slug;
169
171
  this.parentTeamSlugs = parentTeamSlugs;
170
172
  this.zoneId = zoneId;
171
173
  this.errorHandlerConfig = errorHandlerConfig;
174
+ this.webhookAuth = webhookAuth;
172
175
  }
173
176
  get contentHash() {
174
- return (0, canonical_hash_1.canonicalJsonHash)({
177
+ const payload = {
175
178
  zoneId: this.zoneId || null,
176
179
  errorHandlerConfig: this.errorHandlerConfig || null,
177
180
  parentTeamSlugs: [...(this.parentTeamSlugs || [])].sort(),
178
- });
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);
179
189
  }
180
190
  }
181
191
  exports.Team = Team;
@@ -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;
@@ -21,15 +21,10 @@ const PROCESS_FIELDS = `
21
21
  triggers {
22
22
  webhook {
23
23
  enabled
24
+ authMode
24
25
  auth {
25
- basicAuth {
26
- username
27
- password
28
- expression {
29
- username
30
- password
31
- }
32
- }
26
+ headerName
27
+ variableKey
33
28
  }
34
29
  }
35
30
  }
@@ -44,6 +39,7 @@ const PROCESS_FIELDS = `
44
39
  formSettings {
45
40
  enabled
46
41
  appRole
42
+ authMode
47
43
  }
48
44
  dependencies {
49
45
  scopedToProcess
@@ -277,6 +273,9 @@ class FcodeGraphQLClient {
277
273
  if (process.form.appRole) {
278
274
  settings.formSettings.appRole = process.form.appRole;
279
275
  }
276
+ if (process.form.authMode) {
277
+ settings.formSettings.authMode = process.form.authMode;
278
+ }
280
279
  }
281
280
  const input = {
282
281
  name: process.name,
@@ -292,11 +291,17 @@ class FcodeGraphQLClient {
292
291
  }
293
292
  if (process.webhook) {
294
293
  const webhook = { enabled: process.webhook.enabled };
295
- if (process.webhook.username !== undefined) {
296
- webhook.username = process.webhook.username;
297
- }
298
- if (process.webhook.password !== undefined) {
299
- webhook.password = process.webhook.password;
294
+ // Forwarded including an explicit NONE, so a webhook can be un-protected from
295
+ // metadata.json; omitting the mode leaves the stored one in place.
296
+ if (process.webhook.authMode !== undefined) {
297
+ webhook.authMode = process.webhook.authMode;
298
+ if (process.webhook.auth) {
299
+ const auth = { variableKey: process.webhook.auth.variableKey };
300
+ if (process.webhook.auth.headerName !== undefined) {
301
+ auth.headerName = process.webhook.auth.headerName;
302
+ }
303
+ webhook.auth = auth;
304
+ }
300
305
  }
301
306
  input.webhook = webhook;
302
307
  }
@@ -532,23 +537,24 @@ class FcodeGraphQLClient {
532
537
  inheritedTeam(resolvingTeamSlug) {
533
538
  return resolvingTeamSlug && resolvingTeamSlug !== this.teamSlug ? resolvingTeamSlug : undefined;
534
539
  }
540
+ /**
541
+ * The public mode and the default header name are left out, so a public webhook — or one
542
+ * using the default header — keeps the metadata.json it already had on disk.
543
+ */
535
544
  mapWebhook(triggers) {
536
545
  const webhookTrigger = triggers?.webhook;
537
546
  if (!webhookTrigger) {
538
547
  return undefined;
539
548
  }
540
549
  const webhook = { enabled: Boolean(webhookTrigger.enabled) };
541
- const basicAuth = webhookTrigger.auth?.basicAuth;
542
- if (basicAuth) {
543
- // Prefer the expression form (env-var placeholders like $WEBHOOK_PASSWORD)
544
- // over the resolved literal so placeholders round-trip on pull.
545
- const username = basicAuth.expression?.username ?? basicAuth.username;
546
- const password = basicAuth.expression?.password ?? basicAuth.password;
547
- if (username != null) {
548
- webhook.username = username;
549
- }
550
- if (password != null) {
551
- webhook.password = password;
550
+ if (webhookTrigger.authMode && webhookTrigger.authMode !== 'NONE') {
551
+ webhook.authMode = webhookTrigger.authMode;
552
+ if (webhookTrigger.auth?.variableKey) {
553
+ const auth = { variableKey: webhookTrigger.auth.variableKey };
554
+ if (webhookTrigger.auth.headerName && webhookTrigger.auth.headerName !== types_1.DEFAULT_WEBHOOK_AUTH_HEADER) {
555
+ auth.headerName = webhookTrigger.auth.headerName;
556
+ }
557
+ webhook.auth = auth;
552
558
  }
553
559
  }
554
560
  return webhook;
@@ -570,9 +576,33 @@ class FcodeGraphQLClient {
570
576
  if (settings.formSettings.appRole && settings.formSettings.appRole !== 'NONE') {
571
577
  metadata.form.appRole = settings.formSettings.appRole;
572
578
  }
579
+ if (settings.formSettings.authMode && settings.formSettings.authMode !== 'NONE') {
580
+ metadata.form.authMode = settings.formSettings.authMode;
581
+ }
573
582
  }
574
583
  return new types_1.Process(id, slug, script, readme, { dependencies }, settings, metadata, this.inheritedTeam(resolvingTeamSlug));
575
584
  }
585
+ /**
586
+ * GraphQL nests the webhook configuration under `settings`, while team.json and the REST API
587
+ * keep it flat. Mapping it explicitly is what keeps that mismatch from silently reading as
588
+ * undefined.
589
+ */
590
+ mapTeamSettings(team) {
591
+ const settings = {
592
+ slug: team.slug,
593
+ zoneId: team.zoneId,
594
+ errorHandlerConfig: team.errorHandlerConfig,
595
+ parentTeamSlugs: team.parentTeamSlugs ?? [],
596
+ };
597
+ const webhookAuth = team.settings?.webhookAuth;
598
+ if (webhookAuth?.variableKey) {
599
+ settings.webhookAuth = { variableKey: webhookAuth.variableKey };
600
+ if (webhookAuth.headerName && webhookAuth.headerName !== types_1.DEFAULT_WEBHOOK_AUTH_HEADER) {
601
+ settings.webhookAuth.headerName = webhookAuth.headerName;
602
+ }
603
+ }
604
+ return settings;
605
+ }
576
606
  team() {
577
607
  return this.client
578
608
  .request((0, graphql_request_1.gql) `
@@ -585,10 +615,16 @@ class FcodeGraphQLClient {
585
615
  tag
586
616
  }
587
617
  parentTeamSlugs
618
+ settings {
619
+ webhookAuth {
620
+ headerName
621
+ variableKey
622
+ }
623
+ }
588
624
  }
589
625
  }
590
626
  `, { slug: this.teamSlug })
591
- .then(({ team }) => team)
627
+ .then(({ team }) => this.mapTeamSettings(team))
592
628
  .catch((error) => {
593
629
  return Promise.reject(this.cleanError(error));
594
630
  });
@@ -605,10 +641,16 @@ class FcodeGraphQLClient {
605
641
  tag
606
642
  }
607
643
  parentTeamSlugs
644
+ settings {
645
+ webhookAuth {
646
+ headerName
647
+ variableKey
648
+ }
649
+ }
608
650
  }
609
651
  }
610
652
  `, { slug: this.teamSlug, update })
611
- .then(({ updateTeam: team }) => team)
653
+ .then(({ updateTeam: team }) => this.mapTeamSettings(team))
612
654
  .catch((error) => {
613
655
  return Promise.reject(this.cleanError(error));
614
656
  });