@factorialco/fcode-cli 1.5.0 → 2.1.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.
Files changed (43) hide show
  1. package/README.md +123 -3
  2. package/dist/commands/http.d.ts +6 -4
  3. package/dist/commands/http.js +40 -24
  4. package/dist/commands/setup-debug.js +2 -2
  5. package/dist/commands/team/aliases/delete.d.ts +9 -0
  6. package/dist/commands/team/aliases/delete.js +28 -0
  7. package/dist/commands/team/aliases/list.d.ts +6 -0
  8. package/dist/commands/team/aliases/list.js +23 -0
  9. package/dist/commands/team/aliases/set.d.ts +10 -0
  10. package/dist/commands/team/aliases/set.js +25 -0
  11. package/dist/commands/team/pull.js +8 -1
  12. package/dist/commands/team/push.js +9 -1
  13. package/dist/commands/team/versions/create.d.ts +13 -0
  14. package/dist/commands/team/versions/create.js +42 -0
  15. package/dist/commands/team/versions/delete.d.ts +12 -0
  16. package/dist/commands/team/versions/delete.js +32 -0
  17. package/dist/commands/team/versions/list.d.ts +6 -0
  18. package/dist/commands/team/versions/list.js +25 -0
  19. package/dist/commands/variables/status.js +11 -0
  20. package/dist/config/settings.d.ts +1 -0
  21. package/dist/config/settings.js +6 -0
  22. package/dist/core/types.d.ts +65 -4
  23. package/dist/core/types.js +16 -5
  24. package/dist/env.d.ts +2 -2
  25. package/dist/env.js +1 -1
  26. package/dist/repositories/team.d.ts +10 -2
  27. package/dist/repositories/team.js +53 -3
  28. package/dist/repositories/variable.d.ts +24 -0
  29. package/dist/repositories/variable.js +70 -22
  30. package/dist/runtime/executor.js +2 -2
  31. package/dist/runtime/executor.py +2 -2
  32. package/dist/runtime/libs/python/fcode/__init__.py +2 -2
  33. package/dist/services/auth-service.d.ts +3 -0
  34. package/dist/services/auth-service.js +56 -5
  35. package/dist/services/fcode-graphql-client.d.ts +20 -1
  36. package/dist/services/fcode-graphql-client.js +239 -49
  37. package/dist/services/oauth-browser-service.js +1 -0
  38. package/dist/services/webhook-auth.d.ts +21 -0
  39. package/dist/services/webhook-auth.js +77 -0
  40. package/dist/utils/gitignore.d.ts +2 -0
  41. package/dist/utils/gitignore.js +6 -0
  42. package/oclif.manifest.json +208 -17
  43. package/package.json +10 -2
@@ -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: '' }
@@ -1,5 +1,5 @@
1
1
  import { GraphQLClient } from 'graphql-request';
2
- import { Process, ProgrammingLanguage, ProgrammingLanguageManifest, RawTeamSettings, ScriptLibrary, TeamUpdateInput, Variable, VersionedProcess, VersionedScriptLibrary, ServiceAccount } from '../core/types';
2
+ import { Process, ProgrammingLanguage, ProgrammingLanguageManifest, RawTeamSettings, ScriptLibrary, TeamUpdateInput, TeamVersion, TeamVersionAlias, Variable, VersionedProcess, VersionedScriptLibrary, ServiceAccount } from '../core/types';
3
3
  export type FcodeGraphQLClientOptions = {
4
4
  url: string;
5
5
  accessToken: string;
@@ -32,8 +32,27 @@ 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;
47
+ mapTeamVersion(teamVersion: any): TeamVersion;
48
+ mapTeamVersionAlias(alias: any): TeamVersionAlias;
49
+ allTeamVersions(): Promise<Array<TeamVersion>>;
50
+ createTeamVersion(tag: string, comment?: string): Promise<TeamVersion>;
51
+ deleteTeamVersion(tag: string): Promise<TeamVersion>;
52
+ allTeamVersionAliases(): Promise<Array<TeamVersionAlias>>;
53
+ /** The API upserts: an existing alias with the same name is re-pointed to the tag. */
54
+ createTeamVersionAlias(name: string, tag: string): Promise<TeamVersionAlias>;
55
+ deleteTeamVersionAlias(id: string): Promise<TeamVersionAlias>;
37
56
  team(): Promise<RawTeamSettings>;
38
57
  pushTeam(update: TeamUpdateInput): Promise<RawTeamSettings>;
39
58
  mapProgrammingLanguageManifest(programmingLanguageManifest: any): ProgrammingLanguageManifest;
@@ -3,6 +3,31 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const graphql_request_1 = require("graphql-request");
4
4
  const types_1 = require("../core/types");
5
5
  const _ = require("lodash");
6
+ // Shared team version selections so every query/mutation returns the same shape.
7
+ const TEAM_VERSION_FIELDS = `
8
+ id
9
+ tag
10
+ comment
11
+ createdAt
12
+ createdBy {
13
+ id
14
+ name
15
+ }
16
+ manifest {
17
+ resourceType
18
+ resourceId
19
+ slug
20
+ outcome
21
+ error
22
+ }
23
+ `;
24
+ const TEAM_VERSION_ALIAS_FIELDS = `
25
+ id
26
+ name
27
+ tag
28
+ createdAt
29
+ updatedAt
30
+ `;
6
31
  // Shared process selection so pull queries and push mutations return the same
7
32
  // fields, keeping mapProcess (and therefore contentHash) consistent.
8
33
  const PROCESS_FIELDS = `
@@ -21,15 +46,10 @@ const PROCESS_FIELDS = `
21
46
  triggers {
22
47
  webhook {
23
48
  enabled
49
+ authMode
24
50
  auth {
25
- basicAuth {
26
- username
27
- password
28
- expression {
29
- username
30
- password
31
- }
32
- }
51
+ headerName
52
+ variableKey
33
53
  }
34
54
  }
35
55
  }
@@ -44,6 +64,7 @@ const PROCESS_FIELDS = `
44
64
  formSettings {
45
65
  enabled
46
66
  appRole
67
+ authMode
47
68
  }
48
69
  dependencies {
49
70
  scopedToProcess
@@ -240,33 +261,46 @@ class FcodeGraphQLClient {
240
261
  return Promise.reject(this.cleanError(error));
241
262
  });
242
263
  }
243
- allVariables() {
244
- return this.client
245
- .request((0, graphql_request_1.gql) `
246
- query teamVariables($first: Int!, $after: String) {
247
- teamVariables(first: $first, after: $after) {
248
- edges {
249
- node {
250
- id
251
- key
252
- value
253
- isSensitive
264
+ // Pages through every variable: a workspace lists its own plus the ones inherited from
265
+ // its parent workspaces, so a single page is not enough to be sure nothing is missed.
266
+ async allVariables() {
267
+ const pageSize = 200;
268
+ const variables = [];
269
+ let after = null;
270
+ try {
271
+ do {
272
+ const { teamVariables: { edges, pageInfo }, } = await this.client.request((0, graphql_request_1.gql) `
273
+ query teamVariables($first: Int!, $after: String) {
274
+ teamVariables(first: $first, after: $after) {
275
+ edges {
276
+ node {
277
+ id
278
+ key
279
+ value
280
+ isSensitive
281
+ resolvingTeamSlug
282
+ }
283
+ }
284
+ pageInfo {
285
+ hasNextPage
286
+ endCursor
254
287
  }
255
- }
256
- pageInfo {
257
- hasNextPage
258
- endCursor
259
288
  }
260
289
  }
261
- }
262
- `, {
263
- after: null,
264
- first: 200,
265
- })
266
- .then(({ teamVariables: { edges } }) => edges.map(({ node: { id, isSensitive, key, value } }) => new types_1.Variable(id, key, value, isSensitive)))
267
- .catch((error) => {
290
+ `, {
291
+ after,
292
+ first: pageSize,
293
+ });
294
+ for (const { node: { id, isSensitive, key, value, resolvingTeamSlug }, } of edges) {
295
+ variables.push(new types_1.Variable(id, key, value, isSensitive, this.inheritedTeam(resolvingTeamSlug)));
296
+ }
297
+ after = pageInfo?.hasNextPage ? pageInfo.endCursor : null;
298
+ } while (after);
299
+ }
300
+ catch (error) {
268
301
  return Promise.reject(this.cleanError(error));
269
- });
302
+ }
303
+ return variables;
270
304
  }
271
305
  toProcessInput(process, includeCreateOnly) {
272
306
  const settings = {
@@ -277,6 +311,9 @@ class FcodeGraphQLClient {
277
311
  if (process.form.appRole) {
278
312
  settings.formSettings.appRole = process.form.appRole;
279
313
  }
314
+ if (process.form.authMode) {
315
+ settings.formSettings.authMode = process.form.authMode;
316
+ }
280
317
  }
281
318
  const input = {
282
319
  name: process.name,
@@ -292,11 +329,17 @@ class FcodeGraphQLClient {
292
329
  }
293
330
  if (process.webhook) {
294
331
  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;
332
+ // Forwarded including an explicit NONE, so a webhook can be un-protected from
333
+ // metadata.json; omitting the mode leaves the stored one in place.
334
+ if (process.webhook.authMode !== undefined) {
335
+ webhook.authMode = process.webhook.authMode;
336
+ if (process.webhook.auth) {
337
+ const auth = { variableKey: process.webhook.auth.variableKey };
338
+ if (process.webhook.auth.headerName !== undefined) {
339
+ auth.headerName = process.webhook.auth.headerName;
340
+ }
341
+ webhook.auth = auth;
342
+ }
300
343
  }
301
344
  input.webhook = webhook;
302
345
  }
@@ -532,23 +575,24 @@ class FcodeGraphQLClient {
532
575
  inheritedTeam(resolvingTeamSlug) {
533
576
  return resolvingTeamSlug && resolvingTeamSlug !== this.teamSlug ? resolvingTeamSlug : undefined;
534
577
  }
578
+ /**
579
+ * The public mode and the default header name are left out, so a public webhook — or one
580
+ * using the default header — keeps the metadata.json it already had on disk.
581
+ */
535
582
  mapWebhook(triggers) {
536
583
  const webhookTrigger = triggers?.webhook;
537
584
  if (!webhookTrigger) {
538
585
  return undefined;
539
586
  }
540
587
  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;
588
+ if (webhookTrigger.authMode && webhookTrigger.authMode !== 'NONE') {
589
+ webhook.authMode = webhookTrigger.authMode;
590
+ if (webhookTrigger.auth?.variableKey) {
591
+ const auth = { variableKey: webhookTrigger.auth.variableKey };
592
+ if (webhookTrigger.auth.headerName && webhookTrigger.auth.headerName !== types_1.DEFAULT_WEBHOOK_AUTH_HEADER) {
593
+ auth.headerName = webhookTrigger.auth.headerName;
594
+ }
595
+ webhook.auth = auth;
552
596
  }
553
597
  }
554
598
  return webhook;
@@ -570,9 +614,143 @@ class FcodeGraphQLClient {
570
614
  if (settings.formSettings.appRole && settings.formSettings.appRole !== 'NONE') {
571
615
  metadata.form.appRole = settings.formSettings.appRole;
572
616
  }
617
+ if (settings.formSettings.authMode && settings.formSettings.authMode !== 'NONE') {
618
+ metadata.form.authMode = settings.formSettings.authMode;
619
+ }
573
620
  }
574
621
  return new types_1.Process(id, slug, script, readme, { dependencies }, settings, metadata, this.inheritedTeam(resolvingTeamSlug));
575
622
  }
623
+ /**
624
+ * GraphQL nests the webhook configuration under `settings`, while team.json and the REST API
625
+ * keep it flat. Mapping it explicitly is what keeps that mismatch from silently reading as
626
+ * undefined.
627
+ */
628
+ mapTeamSettings(team) {
629
+ const settings = {
630
+ slug: team.slug,
631
+ zoneId: team.zoneId,
632
+ errorHandlerConfig: team.errorHandlerConfig,
633
+ parentTeamSlugs: team.parentTeamSlugs ?? [],
634
+ };
635
+ const webhookAuth = team.settings?.webhookAuth;
636
+ if (webhookAuth?.variableKey) {
637
+ settings.webhookAuth = { variableKey: webhookAuth.variableKey };
638
+ if (webhookAuth.headerName && webhookAuth.headerName !== types_1.DEFAULT_WEBHOOK_AUTH_HEADER) {
639
+ settings.webhookAuth.headerName = webhookAuth.headerName;
640
+ }
641
+ }
642
+ return settings;
643
+ }
644
+ mapTeamVersion(teamVersion) {
645
+ return {
646
+ id: teamVersion.id,
647
+ tag: teamVersion.tag,
648
+ comment: teamVersion.comment ?? null,
649
+ manifest: (teamVersion.manifest ?? []).map((entry) => ({
650
+ resourceType: entry.resourceType,
651
+ resourceId: entry.resourceId,
652
+ slug: entry.slug,
653
+ outcome: entry.outcome,
654
+ error: entry.error ?? null,
655
+ })),
656
+ createdAt: teamVersion.createdAt,
657
+ createdBy: teamVersion.createdBy ?? null,
658
+ };
659
+ }
660
+ mapTeamVersionAlias(alias) {
661
+ return {
662
+ id: alias.id,
663
+ name: alias.name,
664
+ tag: alias.tag,
665
+ createdAt: alias.createdAt,
666
+ updatedAt: alias.updatedAt ?? null,
667
+ };
668
+ }
669
+ allTeamVersions() {
670
+ return this.client
671
+ .request((0, graphql_request_1.gql) `
672
+ query allTeamVersions {
673
+ allTeamVersions {
674
+ ${TEAM_VERSION_FIELDS}
675
+ }
676
+ }
677
+ `)
678
+ .then(({ allTeamVersions }) => allTeamVersions.map((teamVersion) => this.mapTeamVersion(teamVersion)))
679
+ .catch((error) => {
680
+ return Promise.reject(this.cleanError(error));
681
+ });
682
+ }
683
+ createTeamVersion(tag, comment) {
684
+ return this.client
685
+ .request((0, graphql_request_1.gql) `
686
+ mutation createTeamVersion($tag: String!, $comment: String) {
687
+ createTeamVersion(tag: $tag, comment: $comment) {
688
+ ${TEAM_VERSION_FIELDS}
689
+ }
690
+ }
691
+ `, { tag, comment: comment ?? null })
692
+ .then(({ createTeamVersion }) => this.mapTeamVersion(createTeamVersion))
693
+ .catch((error) => {
694
+ return Promise.reject(this.cleanError(error));
695
+ });
696
+ }
697
+ deleteTeamVersion(tag) {
698
+ return this.client
699
+ .request((0, graphql_request_1.gql) `
700
+ mutation deleteTeamVersion($tag: String!) {
701
+ deleteTeamVersion(tag: $tag) {
702
+ ${TEAM_VERSION_FIELDS}
703
+ }
704
+ }
705
+ `, { tag })
706
+ .then(({ deleteTeamVersion }) => this.mapTeamVersion(deleteTeamVersion))
707
+ .catch((error) => {
708
+ return Promise.reject(this.cleanError(error));
709
+ });
710
+ }
711
+ allTeamVersionAliases() {
712
+ return this.client
713
+ .request((0, graphql_request_1.gql) `
714
+ query allTeamVersionAliases {
715
+ allTeamVersionAliases {
716
+ ${TEAM_VERSION_ALIAS_FIELDS}
717
+ }
718
+ }
719
+ `)
720
+ .then(({ allTeamVersionAliases }) => allTeamVersionAliases.map((alias) => this.mapTeamVersionAlias(alias)))
721
+ .catch((error) => {
722
+ return Promise.reject(this.cleanError(error));
723
+ });
724
+ }
725
+ /** The API upserts: an existing alias with the same name is re-pointed to the tag. */
726
+ createTeamVersionAlias(name, tag) {
727
+ return this.client
728
+ .request((0, graphql_request_1.gql) `
729
+ mutation createTeamVersionAlias($input: TeamVersionAliasInput!) {
730
+ createTeamVersionAlias(input: $input) {
731
+ ${TEAM_VERSION_ALIAS_FIELDS}
732
+ }
733
+ }
734
+ `, { input: { name, tag } })
735
+ .then(({ createTeamVersionAlias }) => this.mapTeamVersionAlias(createTeamVersionAlias))
736
+ .catch((error) => {
737
+ return Promise.reject(this.cleanError(error));
738
+ });
739
+ }
740
+ deleteTeamVersionAlias(id) {
741
+ return this.client
742
+ .request((0, graphql_request_1.gql) `
743
+ mutation deleteTeamVersionAlias($id: ID!) {
744
+ deleteTeamVersionAlias(id: $id) {
745
+ ${TEAM_VERSION_ALIAS_FIELDS}
746
+ }
747
+ }
748
+ `, { id })
749
+ .then(({ deleteTeamVersionAlias }) => this.mapTeamVersionAlias(deleteTeamVersionAlias))
750
+ .catch((error) => {
751
+ return Promise.reject(this.cleanError(error));
752
+ });
753
+ }
576
754
  team() {
577
755
  return this.client
578
756
  .request((0, graphql_request_1.gql) `
@@ -585,10 +763,16 @@ class FcodeGraphQLClient {
585
763
  tag
586
764
  }
587
765
  parentTeamSlugs
766
+ settings {
767
+ webhookAuth {
768
+ headerName
769
+ variableKey
770
+ }
771
+ }
588
772
  }
589
773
  }
590
774
  `, { slug: this.teamSlug })
591
- .then(({ team }) => team)
775
+ .then(({ team }) => this.mapTeamSettings(team))
592
776
  .catch((error) => {
593
777
  return Promise.reject(this.cleanError(error));
594
778
  });
@@ -605,10 +789,16 @@ class FcodeGraphQLClient {
605
789
  tag
606
790
  }
607
791
  parentTeamSlugs
792
+ settings {
793
+ webhookAuth {
794
+ headerName
795
+ variableKey
796
+ }
797
+ }
608
798
  }
609
799
  }
610
800
  `, { slug: this.teamSlug, update })
611
- .then(({ updateTeam: team }) => team)
801
+ .then(({ updateTeam: team }) => this.mapTeamSettings(team))
612
802
  .catch((error) => {
613
803
  return Promise.reject(this.cleanError(error));
614
804
  });
@@ -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
+ }
@@ -13,5 +13,7 @@ export declare function renderGitignoreWithInherited(existing: string, inherited
13
13
  * Refreshes the workspace .gitignore so inherited processes/modules (owned by a
14
14
  * parent team and pulled read-only) are not tracked by git. Regenerated from the
15
15
  * current workspace metadata, so resources that stop being inherited drop out.
16
+ * Inherited variable values live in one file, which is ignored as soon as the
17
+ * workspace inherits any variable.
16
18
  */
17
19
  export declare function syncInheritedGitignore(workspaceDir?: string): Promise<void>;
@@ -6,6 +6,8 @@ exports.syncInheritedGitignore = syncInheritedGitignore;
6
6
  const path = require("node:path");
7
7
  const fs = require("fs-extra");
8
8
  const workspace_1 = require("../config/workspace");
9
+ const global_1 = require("../config/global");
10
+ const settings_1 = require("../config/settings");
9
11
  const filenamify_1 = require("./filenamify");
10
12
  const file_utils_1 = require("./file-utils");
11
13
  exports.INHERITED_BLOCK_START = '# BEGIN fcode inherited resources (auto-generated, do not edit)';
@@ -43,9 +45,12 @@ function stripManagedBlock(content) {
43
45
  * Refreshes the workspace .gitignore so inherited processes/modules (owned by a
44
46
  * parent team and pulled read-only) are not tracked by git. Regenerated from the
45
47
  * current workspace metadata, so resources that stop being inherited drop out.
48
+ * Inherited variable values live in one file, which is ignored as soon as the
49
+ * workspace inherits any variable.
46
50
  */
47
51
  async function syncInheritedGitignore(workspaceDir = process.cwd()) {
48
52
  const team = await new workspace_1.default(workspaceDir).getActiveTeam();
53
+ const inheritedVariablesFileName = await new global_1.default().getSetting(settings_1.SettingKeys.INHERITED_VARIABLES_FILE_NAME);
49
54
  const inheritedPaths = [
50
55
  ...Object.values(team.processes)
51
56
  .filter((process) => process.resolvingTeamSlug)
@@ -53,6 +58,7 @@ async function syncInheritedGitignore(workspaceDir = process.cwd()) {
53
58
  ...Object.values(team.modules)
54
59
  .filter((module) => module.resolvingTeamSlug)
55
60
  .map((module) => `modules/${(0, filenamify_1.default)(module.name)}/`),
61
+ ...(Object.values(team.variables ?? {}).some((variable) => variable.resolvingTeamSlug) ? [inheritedVariablesFileName] : []),
56
62
  ];
57
63
  const gitignorePath = path.join(workspaceDir, '.gitignore');
58
64
  const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : '';