@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 +3 -3
- package/dist/commands/http.d.ts +6 -4
- package/dist/commands/http.js +40 -24
- package/dist/commands/team/push.js +6 -1
- package/dist/core/types.d.ts +29 -9
- package/dist/core/types.js +14 -6
- package/dist/repositories/process.js +0 -4
- package/dist/repositories/team.d.ts +3 -1
- package/dist/repositories/team.js +19 -3
- package/dist/services/auth-service.d.ts +3 -0
- package/dist/services/auth-service.js +56 -5
- package/dist/services/fcode-graphql-client.d.ts +10 -0
- package/dist/services/fcode-graphql-client.js +68 -35
- package/dist/services/oauth-browser-service.js +1 -0
- package/dist/services/webhook-auth.d.ts +21 -0
- package/dist/services/webhook-auth.js +77 -0
- package/oclif.manifest.json +160 -160
- package/package.json +1 -1
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/
|
|
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
|
package/dist/commands/http.d.ts
CHANGED
|
@@ -11,12 +11,14 @@ export default class Http extends Command {
|
|
|
11
11
|
};
|
|
12
12
|
run(): Promise<void>;
|
|
13
13
|
/**
|
|
14
|
-
* Enforces the
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
}
|
package/dist/commands/http.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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;
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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;
|
package/dist/core/types.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
73
|
-
|
|
74
|
-
|
|
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;
|