@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
|
@@ -21,15 +21,10 @@ const PROCESS_FIELDS = `
|
|
|
21
21
|
triggers {
|
|
22
22
|
webhook {
|
|
23
23
|
enabled
|
|
24
|
+
authMode
|
|
24
25
|
auth {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
password
|
|
28
|
-
expression {
|
|
29
|
-
username
|
|
30
|
-
password
|
|
31
|
-
}
|
|
32
|
-
}
|
|
26
|
+
headerName
|
|
27
|
+
variableKey
|
|
33
28
|
}
|
|
34
29
|
}
|
|
35
30
|
}
|
|
@@ -44,9 +39,7 @@ const PROCESS_FIELDS = `
|
|
|
44
39
|
formSettings {
|
|
45
40
|
enabled
|
|
46
41
|
appRole
|
|
47
|
-
|
|
48
|
-
publicationConfig {
|
|
49
|
-
isPublic
|
|
42
|
+
authMode
|
|
50
43
|
}
|
|
51
44
|
dependencies {
|
|
52
45
|
scopedToProcess
|
|
@@ -280,9 +273,9 @@ class FcodeGraphQLClient {
|
|
|
280
273
|
if (process.form.appRole) {
|
|
281
274
|
settings.formSettings.appRole = process.form.appRole;
|
|
282
275
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
276
|
+
if (process.form.authMode) {
|
|
277
|
+
settings.formSettings.authMode = process.form.authMode;
|
|
278
|
+
}
|
|
286
279
|
}
|
|
287
280
|
const input = {
|
|
288
281
|
name: process.name,
|
|
@@ -298,11 +291,17 @@ class FcodeGraphQLClient {
|
|
|
298
291
|
}
|
|
299
292
|
if (process.webhook) {
|
|
300
293
|
const webhook = { enabled: process.webhook.enabled };
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
+
}
|
|
306
305
|
}
|
|
307
306
|
input.webhook = webhook;
|
|
308
307
|
}
|
|
@@ -538,23 +537,24 @@ class FcodeGraphQLClient {
|
|
|
538
537
|
inheritedTeam(resolvingTeamSlug) {
|
|
539
538
|
return resolvingTeamSlug && resolvingTeamSlug !== this.teamSlug ? resolvingTeamSlug : undefined;
|
|
540
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
|
+
*/
|
|
541
544
|
mapWebhook(triggers) {
|
|
542
545
|
const webhookTrigger = triggers?.webhook;
|
|
543
546
|
if (!webhookTrigger) {
|
|
544
547
|
return undefined;
|
|
545
548
|
}
|
|
546
549
|
const webhook = { enabled: Boolean(webhookTrigger.enabled) };
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
webhook.
|
|
555
|
-
}
|
|
556
|
-
if (password != null) {
|
|
557
|
-
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;
|
|
558
558
|
}
|
|
559
559
|
}
|
|
560
560
|
return webhook;
|
|
@@ -576,12 +576,33 @@ class FcodeGraphQLClient {
|
|
|
576
576
|
if (settings.formSettings.appRole && settings.formSettings.appRole !== 'NONE') {
|
|
577
577
|
metadata.form.appRole = settings.formSettings.appRole;
|
|
578
578
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
579
|
+
if (settings.formSettings.authMode && settings.formSettings.authMode !== 'NONE') {
|
|
580
|
+
metadata.form.authMode = settings.formSettings.authMode;
|
|
581
|
+
}
|
|
582
582
|
}
|
|
583
583
|
return new types_1.Process(id, slug, script, readme, { dependencies }, settings, metadata, this.inheritedTeam(resolvingTeamSlug));
|
|
584
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
|
+
}
|
|
585
606
|
team() {
|
|
586
607
|
return this.client
|
|
587
608
|
.request((0, graphql_request_1.gql) `
|
|
@@ -594,10 +615,16 @@ class FcodeGraphQLClient {
|
|
|
594
615
|
tag
|
|
595
616
|
}
|
|
596
617
|
parentTeamSlugs
|
|
618
|
+
settings {
|
|
619
|
+
webhookAuth {
|
|
620
|
+
headerName
|
|
621
|
+
variableKey
|
|
622
|
+
}
|
|
623
|
+
}
|
|
597
624
|
}
|
|
598
625
|
}
|
|
599
626
|
`, { slug: this.teamSlug })
|
|
600
|
-
.then(({ team }) => team)
|
|
627
|
+
.then(({ team }) => this.mapTeamSettings(team))
|
|
601
628
|
.catch((error) => {
|
|
602
629
|
return Promise.reject(this.cleanError(error));
|
|
603
630
|
});
|
|
@@ -614,10 +641,16 @@ class FcodeGraphQLClient {
|
|
|
614
641
|
tag
|
|
615
642
|
}
|
|
616
643
|
parentTeamSlugs
|
|
644
|
+
settings {
|
|
645
|
+
webhookAuth {
|
|
646
|
+
headerName
|
|
647
|
+
variableKey
|
|
648
|
+
}
|
|
649
|
+
}
|
|
617
650
|
}
|
|
618
651
|
}
|
|
619
652
|
`, { slug: this.teamSlug, update })
|
|
620
|
-
.then(({ updateTeam: team }) => team)
|
|
653
|
+
.then(({ updateTeam: team }) => this.mapTeamSettings(team))
|
|
621
654
|
.catch((error) => {
|
|
622
655
|
return Promise.reject(this.cleanError(error));
|
|
623
656
|
});
|
|
@@ -144,6 +144,7 @@ async function loginViaBrowser(url, authUrl, clientId) {
|
|
|
144
144
|
const token = {
|
|
145
145
|
access_token: tokenSet.access_token ?? '',
|
|
146
146
|
refresh_token: tokenSet.refresh_token ?? '',
|
|
147
|
+
expires_at: tokenSet.expires_at,
|
|
147
148
|
};
|
|
148
149
|
const userInfo = await auth_service_1.default.getUserInfo(normalizedAuthUrl, token);
|
|
149
150
|
const authInfo = {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type * as http from 'node:http';
|
|
2
|
+
import { ProcessWebhook, WebhookAuth } from '../core/types';
|
|
3
|
+
export type WebhookAuthResult = {
|
|
4
|
+
authorized: true;
|
|
5
|
+
} | {
|
|
6
|
+
authorized: false;
|
|
7
|
+
reason: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Node lower-cases incoming header names, while a configured name is stored as the user typed it,
|
|
11
|
+
* so the lookup has to be case-insensitive. A repeated header arrives as an array and is rejected
|
|
12
|
+
* rather than joined: a spliced value must never authenticate.
|
|
13
|
+
*/
|
|
14
|
+
export declare function findHeader(headers: http.IncomingHttpHeaders, name: string): string | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* The configuration a webhook actually enforces: its own when CUSTOM, the workspace's when TEAM.
|
|
17
|
+
* Returns null only when the webhook is public — TEAM without a workspace configuration throws,
|
|
18
|
+
* because treating it as public would silently unprotect a webhook its owner chose to protect.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveEffectiveAuth(webhook: ProcessWebhook | undefined, teamWebhookAuth: WebhookAuth | undefined): WebhookAuth | null;
|
|
21
|
+
export declare function checkWebhookAuth(effectiveAuth: WebhookAuth | null, headers: http.IncomingHttpHeaders, variableValue: string | undefined): WebhookAuthResult;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.findHeader = findHeader;
|
|
4
|
+
exports.resolveEffectiveAuth = resolveEffectiveAuth;
|
|
5
|
+
exports.checkWebhookAuth = checkWebhookAuth;
|
|
6
|
+
const crypto = require("node:crypto");
|
|
7
|
+
const types_1 = require("../core/types");
|
|
8
|
+
/**
|
|
9
|
+
* Mirrors the cloud's webhook authentication so `fcode http` accepts and rejects exactly what the
|
|
10
|
+
* platform does. The scheme in the default Authorization header is case-insensitive per RFC 7235;
|
|
11
|
+
* the token is not. A custom header carries the raw value, with no scheme to parse.
|
|
12
|
+
*/
|
|
13
|
+
const BEARER_PATTERN = /^Bearer[ \t]+(\S+)[ \t]*$/i;
|
|
14
|
+
/**
|
|
15
|
+
* Node lower-cases incoming header names, while a configured name is stored as the user typed it,
|
|
16
|
+
* so the lookup has to be case-insensitive. A repeated header arrives as an array and is rejected
|
|
17
|
+
* rather than joined: a spliced value must never authenticate.
|
|
18
|
+
*/
|
|
19
|
+
function findHeader(headers, name) {
|
|
20
|
+
const lowerCased = name.toLowerCase();
|
|
21
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
22
|
+
if (key.toLowerCase() === lowerCased) {
|
|
23
|
+
return typeof value === 'string' ? value : undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The configuration a webhook actually enforces: its own when CUSTOM, the workspace's when TEAM.
|
|
30
|
+
* Returns null only when the webhook is public — TEAM without a workspace configuration throws,
|
|
31
|
+
* because treating it as public would silently unprotect a webhook its owner chose to protect.
|
|
32
|
+
*/
|
|
33
|
+
function resolveEffectiveAuth(webhook, teamWebhookAuth) {
|
|
34
|
+
const authMode = webhook?.authMode;
|
|
35
|
+
if (!authMode || authMode === 'NONE') {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
if (authMode === 'TEAM') {
|
|
39
|
+
if (!teamWebhookAuth) {
|
|
40
|
+
throw new Error('this webhook inherits the workspace webhook auth, which is not set in team.json');
|
|
41
|
+
}
|
|
42
|
+
return teamWebhookAuth;
|
|
43
|
+
}
|
|
44
|
+
if (!webhook?.auth?.variableKey) {
|
|
45
|
+
throw new Error('this webhook uses the CUSTOM auth mode but names no variable in metadata.json');
|
|
46
|
+
}
|
|
47
|
+
return webhook.auth;
|
|
48
|
+
}
|
|
49
|
+
/** Constant-time over equal-length digests, so neither the token nor its length leaks. */
|
|
50
|
+
function matches(expectedToken, givenToken) {
|
|
51
|
+
const digest = (value) => crypto.createHash('sha256').update(value, 'utf8').digest();
|
|
52
|
+
return crypto.timingSafeEqual(digest(expectedToken), digest(givenToken));
|
|
53
|
+
}
|
|
54
|
+
function checkWebhookAuth(effectiveAuth, headers, variableValue) {
|
|
55
|
+
if (effectiveAuth === null) {
|
|
56
|
+
return { authorized: true };
|
|
57
|
+
}
|
|
58
|
+
const headerName = effectiveAuth.headerName ?? types_1.DEFAULT_WEBHOOK_AUTH_HEADER;
|
|
59
|
+
const headerValue = findHeader(headers, headerName);
|
|
60
|
+
if (headerValue === undefined || headerValue.trim().length === 0) {
|
|
61
|
+
return { authorized: false, reason: `the ${headerName} header is missing` };
|
|
62
|
+
}
|
|
63
|
+
let givenToken = headerValue;
|
|
64
|
+
if (headerName.toLowerCase() === types_1.DEFAULT_WEBHOOK_AUTH_HEADER.toLowerCase()) {
|
|
65
|
+
givenToken = BEARER_PATTERN.exec(headerValue)?.[1];
|
|
66
|
+
if (givenToken === undefined) {
|
|
67
|
+
return { authorized: false, reason: `the ${headerName} header is not a bearer token` };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (variableValue === undefined) {
|
|
71
|
+
return { authorized: false, reason: `the ${effectiveAuth.variableKey} variable is not defined` };
|
|
72
|
+
}
|
|
73
|
+
if (!matches(variableValue, givenToken)) {
|
|
74
|
+
return { authorized: false, reason: `the value sent in ${headerName} does not match ${effectiveAuth.variableKey}` };
|
|
75
|
+
}
|
|
76
|
+
return { authorized: true };
|
|
77
|
+
}
|