Skip to content

Commit c68084b

Browse files
authored
Merge pull request #6 from StackExchange/basic-business
feat: added support for Basic and Business tiers by allowing manual input of PAT
2 parents 7f87238 + 15410e7 commit c68084b

File tree

17 files changed

+497
-95
lines changed

17 files changed

+497
-95
lines changed

README.md

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,33 @@ This image runs a Backstage instance pre-configured with the Stack Overflow for
4040

4141
---
4242

43-
### Required Environment Variables
43+
## 📦 Required Environment Variables
4444

45-
| Variable | Description |
46-
|:----------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
47-
| `STACK_OVERFLOW_INSTANCE_URL` | The base URL of your Stack Overflow for Teams (Enterprise) instance. |
48-
| `STACK_OVERFLOW_API_ACCESS_TOKEN` | A **read-only, no-expiry** API access token generated for your Stack Overflow Enterprise instance. This token is used by the plugin's search collator to index questions into Backstage search. |
49-
| `STACK_OVERFLOW_CLIENT_ID` | The OAuth Client ID from your Stack Overflow application. This is required to enable the secure question creation flow from within Backstage. |
45+
### For **Enterprise** Customers:
46+
47+
| Variable | Description |
48+
| :-------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
49+
| `STACK_OVERFLOW_INSTANCE_URL` | The base URL of your Stack Overflow for Teams (Enterprise) instance. |
50+
| `STACK_OVERFLOW_API_ACCESS_TOKEN` | A **read-only, no-expiry** API access token generated for your Stack Overflow Enterprise instance. This token is used by the plugin’s search collator to index questions into Backstage search. |
51+
| `STACK_OVERFLOW_CLIENT_ID` | The OAuth Client ID from your Stack Overflow application. This is required to enable the secure question creation flow from within Backstage. |
5052
| `STACK_OVERFLOW_REDIRECT_URI` | The redirect URI where Stack Overflow should send users after completing the OAuth authentication flow. By default, this is `{app.baseUrl}/stack-overflow-teams`. For local development, you can use a redirect service like `http://redirectmeto.com/http://localhost:7007/stack-overflow-teams`. |
5153

54+
---
55+
56+
### For **Basic** and **Business** Customers:
57+
58+
| Variable | Description |
59+
| :-------------------------------- | :----------------------------------------------------------------------------------------------------------------------- |
60+
| `STACK_OVERFLOW_TEAM_NAME` | The **team name** or **team slug** from your Stack Overflow for Teams account. |
61+
| `STACK_OVERFLOW_API_ACCESS_TOKEN` | A **read-only, no-expiry** API access token generated for your Stack Overflow Teams instance. Used for indexing content. |
62+
63+
📖 How to generate your API Access Token
64+
65+
Basic and Business customers can follow the official Stack Overflow for Teams guide to create a Personal Access Token (PAT) for API authentication:
66+
67+
👉 [Personal Access Tokens (PATs) for API Authentication](https://stackoverflowteams.help/en/articles/10908790-personal-access-tokens-pats-for-api-authentication)
68+
69+
This token should have read-only access and no expiration to be used for indexing questions into Backstage search.
5270

5371
---
5472

app-config.docker-local.yaml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,19 @@ organization:
1212

1313
stackoverflow:
1414
baseUrl: ${STACK_OVERFLOW_INSTANCE_URL}
15-
# teamName: ${STACK_OVERFLOW_TEAM_NAME}
15+
# Required only for Enteprise Tier.
16+
17+
teamName: ${STACK_OVERFLOW_TEAM_NAME}
18+
# Required only for Basic and Business Tiers.
1619

1720
apiAccessToken: ${STACK_OVERFLOW_API_ACCESS_TOKEN}
1821
# The API Access Token is used for the Questions' collator, a no-expiry, read-only token is recommended.
1922

2023
clientId: ${STACK_OVERFLOW_CLIENT_ID}
21-
# The clientid must be for an API Application with read-write access.
24+
# The clientid must be for an API Application with read-write access. Only provide if you are using the Enterprise tier.
2225

2326
redirectUri: ${STACK_OVERFLOW_REDIRECT_URI}
24-
# If no redirectUri is specified this will return to https://<backstage-domain>/stack-overflow-teams
27+
# If no redirectUri is specified this will return to https://<backstage-domain>/stack-overflow-teams. Only provide if you are using the Enterprise tier.
2528

2629
backend:
2730
# Used for enabling authentication, secret is shared by all backend plugins

app-config.yaml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,24 @@ app:
55
organization:
66
name: My Company
77

8+
# For Basic and Business tiers, only provide teamName and apiAccessToken
9+
# For Enterprise tier DO NOT provide teamName.
10+
811
stackoverflow:
912
baseUrl: ${STACK_OVERFLOW_INSTANCE_URL}
10-
# teamName: ${STACK_OVERFLOW_TEAM_NAME}
13+
# Required only for Enteprise Tier.
14+
15+
teamName: ${STACK_OVERFLOW_TEAM_NAME}
16+
# Required only for Basic and Business Tiers.
1117

1218
apiAccessToken: ${STACK_OVERFLOW_API_ACCESS_TOKEN}
1319
# The API Access Token is used for the Questions' collator, a no-expiry, read-only token is recommended.
1420

1521
clientId: ${STACK_OVERFLOW_CLIENT_ID}
16-
# The clientid must be for an API Application with read-write access.
22+
# The clientid must be for an API Application with read-write access. Only provide if you are using the Enterprise tier.
1723

1824
redirectUri: ${STACK_OVERFLOW_REDIRECT_URI}
19-
# If no redirectUri is specified this will return to https://<backstage-domain>/stack-overflow-teams
25+
# If no redirectUri is specified this will return to https://<backstage-domain>/stack-overflow-teams. Only provide if you are using the Enterprise tier.
2026

2127
backend:
2228
# Used for enabling authentication, secret is shared by all backend plugins

plugins/search-backend-module-stack-overflow-teams-collator/config.d.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ export interface Config {
2020
*/
2121
stackoverflow: {
2222
/**
23-
* The base url of the Stack Overflow API used for the plugin
23+
* The base url of the Stack Overflow API used for the plugin, if no BaseUrl is provided it will default to https://api.stackoverflowteams.com
2424
*/
25-
baseUrl: string;
25+
baseUrl?: string;
2626

2727
/**
2828
* The API Access Token to authenticate to Stack Overflow API Version 3
@@ -31,7 +31,7 @@ export interface Config {
3131
apiAccessToken: string;
3232

3333
/**
34-
* The name of the team for a Stack Overflow for Teams account
34+
* The name of the team for a Stack Overflow for Teams account. When teamName is provided baseUrl will always be https://api.stackoverflowteams.com
3535
*/
3636
teamName?: string;
3737

plugins/search-backend-module-stack-overflow-teams-collator/src/collators/StackOverflowQuestionsCollatorFactory.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,8 @@ export class StackOverflowQuestionsCollatorFactory
8989
private forceOriginUrl = (baseUrl: string): string =>
9090
`${new URL(baseUrl).origin}`;
9191

92-
private constructor(options: StackOverflowQuestionsCollatorFactoryOptions & { baseUrl: string }) {
93-
this.baseUrl = this.forceOriginUrl(options.baseUrl);
92+
private constructor(options: StackOverflowQuestionsCollatorFactoryOptions & { baseUrl?: string }) {
93+
this.baseUrl = this.forceOriginUrl(options.baseUrl || this.stackOverflowTeamsAPI);
9494
this.apiAccessToken = options.apiAccessToken;
9595
this.teamName = options.teamName;
9696
this.logger = options.logger.child({ documentType: this.type });
@@ -110,7 +110,7 @@ export class StackOverflowQuestionsCollatorFactory
110110
) {
111111
const apiAccessToken = config.getString('stackoverflow.apiAccessToken');
112112
const teamName = config.getOptionalString('stackoverflow.teamName');
113-
const baseUrl = config.getString('stackoverflow.baseUrl');
113+
const baseUrl = config.getOptionalString('stackoverflow.baseUrl');
114114
const requestParams = config
115115
.getOptionalConfig('stackoverflow.requestParams')
116116
?.get<StackOverflowQuestionsRequestParams>();
@@ -133,12 +133,18 @@ export class StackOverflowQuestionsCollatorFactory
133133
async *execute(): AsyncGenerator<StackOverflowDocument> {
134134
this.logger.info(`Retrieving data using Stack Overflow API Version 3`);
135135

136-
if (!this.baseUrl) {
137-
this.logger.error(
138-
`No stackoverflow.baseUrl configured in your app-config.yaml`,
136+
if (!this.baseUrl && this.teamName) {
137+
this.logger.info(
138+
`Connecting to the Teams API at https://api.stackoverflowteams.com`,
139139
);
140140
}
141141

142+
if (!this.baseUrl && !this.teamName) {
143+
this.logger.error(
144+
`No stackoverflow.teamName has been provided while trying to connect to the Teams API.`
145+
)
146+
}
147+
142148
const params = qs.stringify(this.requestParams, {
143149
arrayFormat: 'comma',
144150
addQueryPrefix: true,
@@ -147,13 +153,21 @@ export class StackOverflowQuestionsCollatorFactory
147153
let requestUrl;
148154

149155
if (this.teamName) {
150-
const basePath =
151-
this.baseUrl === this.stackOverflowTeamsAPI ? '/v3' : '/api/v3';
152-
requestUrl = `${this.baseUrl}${basePath}/teams/${this.teamName}/questions${params}`;
156+
requestUrl = `${this.stackOverflowTeamsAPI}/v3/teams/${this.teamName}/questions${params}`;
153157
} else {
154158
requestUrl = `${this.baseUrl}/api/v3/questions${params}`;
155159
}
156160

161+
// The code below has been commented, it has potential compatiblity with Enterprise Private Teams but I haven't tested it and since Private Teams is not widely used I've decided to change the logic to prioritise the support for the Basic and Business Teams.
162+
163+
// if (this.teamName) {
164+
// const basePath =
165+
// this.baseUrl === this.stackOverflowTeamsAPI ? '/v3' : '/api/v3';
166+
// requestUrl = `${this.baseUrl}${basePath}/teams/${this.teamName}/questions${params}`;
167+
// } else {
168+
// requestUrl = `${this.baseUrl}/api/v3/questions${params}`;
169+
// }
170+
157171
let page = 1;
158172
let totalPages = 1;
159173
const pageSize = this.requestParams.pageSize || 50;

plugins/stack-overflow-teams-backend/config.d.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,17 @@ export interface Config {
3333
apiAccessToken: string;
3434

3535
/**
36-
* The name of the team for a Stack Overflow for Teams account
36+
* The name of the team for a Stack Overflow for Teams account, required for Basic and Business tiers.
3737
*/
3838
teamName?: string;
3939

4040
/**
41-
* Client Id for the OAuth Application, required to use the Stack Overflow for Teams Hub and write actions.
41+
* Client Id for the OAuth Application, required only for Stack Overflow Enterprise and write actions.
4242
*/
43-
clientId: number;
43+
clientId?: number;
4444

4545
/**
46-
* RedirectUri for the OAuth Application, required to use the Stack Overflow for Teams Hub and write actions.
46+
* RedirectUri for the OAuth Application, required only for Stack Overflow Enterprise and write actions.
4747
*
4848
* This should be your Backstage application domain ending in the plugin's <StackOverflowTeamsPage /> route
4949
* If not specified this will got to your <app.baseUrl>/stack-overflow-teams

plugins/stack-overflow-teams-backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"@backstage/plugin-catalog-node": "^1.15.0",
4141
"@backstage/plugin-search-backend-node": "^1.3.8",
4242
"@backstage/plugin-search-common": "^1.2.17",
43+
"csrf": "^3.1.0",
4344
"express": "^4.17.1",
4445
"express-promise-router": "^4.1.0",
4546
"jsonwebtoken": "^9.0.2",

plugins/stack-overflow-teams-backend/src/api/createStackOverflowApi.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export const createStackOverflowApi = (baseUrl: string) => {
99
pageSize?: number
1010
): Promise<T> => {
1111
let url = teamName
12-
? `${baseUrl}/api/v3/teams/${teamName}${endpoint}`
12+
? `${baseUrl}/v3/teams/${teamName}${endpoint}`
1313
: `${baseUrl}/api/v3${endpoint}`;
1414

1515
const queryParams = new URLSearchParams();

plugins/stack-overflow-teams-backend/src/api/createStackOverflowAuth.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ export function createStackOverflowAuth(
66
config: StackOverflowConfig,
77
logger: LoggerService,
88
) {
9-
109
async function generatePKCECodeVerifier(): Promise<{
1110
codeVerifier: string;
1211
codeChallenge: string;
@@ -19,7 +18,16 @@ export function createStackOverflowAuth(
1918
return { codeVerifier, codeChallenge: hashed };
2019
}
2120

22-
async function getAuthUrl(): Promise<{ url: string; codeVerifier: string ; state: string}> {
21+
async function getAuthUrl(): Promise<{
22+
url: string;
23+
codeVerifier: string;
24+
state: string;
25+
}> {
26+
if (!config.clientId || !config.redirectUri) {
27+
throw new Error(
28+
'clientId and redirectUri are required for authentication',
29+
);
30+
}
2331
const { codeVerifier, codeChallenge } = await generatePKCECodeVerifier();
2432
const state = crypto.randomBytes(16).toString('hex');
2533
const authUrl = `${config.baseUrl}/oauth?client_id=${
@@ -34,20 +42,24 @@ export function createStackOverflowAuth(
3442
async function exchangeCodeForToken(
3543
code: string,
3644
codeVerifier: string,
37-
): Promise<{accessToken: string, expires: number}> {
45+
): Promise<{ accessToken: string; expires: number }> {
46+
if (!config.clientId || !config.redirectUri) {
47+
throw new Error(
48+
'clientId and redirectUri are required for authentication',
49+
);
50+
}
3851
const tokenUrl = `${config.baseUrl}/oauth/access_token/json`;
3952
const queryParams = new URLSearchParams({
4053
client_id: String(config.clientId),
4154
code,
4255
redirect_uri: config.redirectUri,
4356
code_verifier: codeVerifier,
4457
});
45-
58+
4659
const response = await fetch(`${tokenUrl}?${queryParams.toString()}`, {
4760
method: 'POST',
4861
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
4962
});
50-
5163

5264
if (!response.ok) {
5365
logger.error('Failed to exchange code for access token');
@@ -56,13 +68,13 @@ export function createStackOverflowAuth(
5668
const data = await response.json();
5769
return {
5870
accessToken: data.access_token,
59-
expires: data.expires
60-
}
71+
expires: data.expires,
72+
};
6173
}
6274

6375
return {
6476
getAuthUrl,
6577
exchangeCodeForToken,
66-
config: config
78+
config: config,
6779
};
6880
}

plugins/stack-overflow-teams-backend/src/plugin.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,28 @@ export const stackOverflowTeamsPlugin = createBackendPlugin({
2222
config: coreServices.rootConfig,
2323
},
2424
async init({ logger, httpRouter, config }) {
25-
const forceOriginUrl = (baseUrl: string) : string => `${new URL(baseUrl).origin}`
25+
const forceOriginUrl = (baseUrl: string): string =>
26+
`${new URL(baseUrl).origin}`;
27+
28+
const teamName = config.getOptionalString('stackoverflow.teamName');
29+
30+
// If teamName is provided, always use api.stackoverflowteams.com
31+
const baseUrl = teamName
32+
? 'https://api.stackoverflowteams.com'
33+
: forceOriginUrl(
34+
config.getOptionalString('stackoverflow.baseUrl') ||
35+
'https://api.stackoverflowteams.com',
36+
);
37+
2638
const stackOverflowConfig: StackOverflowConfig = {
27-
baseUrl: forceOriginUrl(config.getString('stackoverflow.baseUrl')),
28-
teamName: config.getOptionalString('stackoverflow.teamName'),
29-
clientId: config.getNumber('stackoverflow.clientId'),
30-
redirectUri: config.getOptionalString('stackoverflow.redirectUri') || `${config.getString('app.baseUrl')}/stack-overflow-teams`
39+
baseUrl,
40+
teamName,
41+
clientId: config.getOptionalNumber('stackoverflow.clientId'),
42+
redirectUri:
43+
config.getOptionalString('stackoverflow.redirectUri') ||
44+
`${config.getString('app.baseUrl')}/stack-overflow-teams`,
3145
};
46+
3247
const stackOverflowService = await createStackOverflowService({
3348
config: stackOverflowConfig,
3449
logger,
@@ -38,7 +53,7 @@ export const stackOverflowTeamsPlugin = createBackendPlugin({
3853
await createRouter({
3954
stackOverflowConfig,
4055
logger,
41-
stackOverflowService
56+
stackOverflowService,
4257
}),
4358
);
4459
},

0 commit comments

Comments
 (0)