@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.
@@ -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,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
- if (process.visibility) {
285
- settings.publicationConfig = { isPublic: process.visibility.isPublic };
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
- if (process.webhook.username !== undefined) {
302
- webhook.username = process.webhook.username;
303
- }
304
- if (process.webhook.password !== undefined) {
305
- 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
+ }
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
- const basicAuth = webhookTrigger.auth?.basicAuth;
548
- if (basicAuth) {
549
- // Prefer the expression form (env-var placeholders like $WEBHOOK_PASSWORD)
550
- // over the resolved literal so placeholders round-trip on pull.
551
- const username = basicAuth.expression?.username ?? basicAuth.username;
552
- const password = basicAuth.expression?.password ?? basicAuth.password;
553
- if (username != null) {
554
- webhook.username = username;
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
- if (settings?.publicationConfig) {
581
- metadata.visibility = { isPublic: Boolean(settings.publicationConfig.isPublic) };
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
+ }