@every-app/sdk 0.2.0 → 0.3.0-rc.1
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 +608 -2
- package/dist/cloudflare/index.d.ts +22 -0
- package/dist/cloudflare/index.d.ts.map +1 -0
- package/dist/cloudflare/index.js +21 -0
- package/dist/cloudflare/sentinel.d.ts +69 -0
- package/dist/cloudflare/sentinel.d.ts.map +1 -0
- package/dist/cloudflare/sentinel.js +50 -0
- package/dist/cloudflare/worker.d.ts +511 -0
- package/dist/cloudflare/worker.d.ts.map +1 -0
- package/dist/cloudflare/worker.js +738 -0
- package/dist/dev/index.d.ts +8 -0
- package/dist/dev/index.d.ts.map +1 -0
- package/dist/dev/index.js +7 -0
- package/dist/dev/keypair.d.ts +24 -0
- package/dist/dev/keypair.d.ts.map +1 -0
- package/dist/dev/keypair.js +60 -0
- package/dist/internal/aiProviders.d.ts +67 -0
- package/dist/internal/aiProviders.d.ts.map +1 -0
- package/dist/internal/aiProviders.js +220 -0
- package/dist/internal/appSettings.d.ts +237 -0
- package/dist/internal/appSettings.d.ts.map +1 -0
- package/dist/internal/appSettings.js +374 -0
- package/dist/internal/capabilities.d.ts +1344 -0
- package/dist/internal/capabilities.d.ts.map +1 -0
- package/dist/internal/capabilities.js +1521 -0
- package/dist/internal/devProviders.d.ts +49 -0
- package/dist/internal/devProviders.d.ts.map +1 -0
- package/dist/internal/devProviders.js +62 -0
- package/dist/internal/email.d.ts +280 -0
- package/dist/internal/email.d.ts.map +1 -0
- package/dist/internal/email.js +613 -0
- package/dist/internal/events.d.ts +182 -0
- package/dist/internal/events.d.ts.map +1 -0
- package/dist/internal/events.js +324 -0
- package/dist/internal/gatewaySettings.d.ts +82 -0
- package/dist/internal/gatewaySettings.d.ts.map +1 -0
- package/dist/internal/gatewaySettings.js +209 -0
- package/dist/internal/identity.d.ts +18 -3
- package/dist/internal/identity.d.ts.map +1 -1
- package/dist/internal/identity.js +53 -14
- package/dist/internal/index.d.ts +17 -1
- package/dist/internal/index.d.ts.map +1 -1
- package/dist/internal/index.js +17 -1
- package/dist/internal/members.d.ts +35 -0
- package/dist/internal/members.d.ts.map +1 -0
- package/dist/internal/members.js +7 -0
- package/dist/internal/oauthClient.d.ts +63 -0
- package/dist/internal/oauthClient.d.ts.map +1 -0
- package/dist/internal/oauthClient.js +194 -0
- package/dist/internal/oauthScopes.d.ts +117 -0
- package/dist/internal/oauthScopes.d.ts.map +1 -0
- package/dist/internal/oauthScopes.js +416 -0
- package/dist/internal/organizationName.d.ts +18 -0
- package/dist/internal/organizationName.d.ts.map +1 -0
- package/dist/internal/organizationName.js +24 -0
- package/dist/internal/protocol.d.ts +86 -2
- package/dist/internal/protocol.d.ts.map +1 -1
- package/dist/internal/protocol.js +112 -1
- package/dist/internal/providerApps.d.ts +75 -0
- package/dist/internal/providerApps.d.ts.map +1 -0
- package/dist/internal/providerApps.js +59 -0
- package/dist/internal/providerRegistry.d.ts +147 -0
- package/dist/internal/providerRegistry.d.ts.map +1 -0
- package/dist/internal/providerRegistry.js +262 -0
- package/dist/internal/providerSpec.d.ts +55 -0
- package/dist/internal/providerSpec.d.ts.map +1 -0
- package/dist/internal/providerSpec.js +746 -0
- package/dist/internal/remoteMcp.d.ts +188 -0
- package/dist/internal/remoteMcp.d.ts.map +1 -0
- package/dist/internal/remoteMcp.js +543 -0
- package/dist/internal/tools.d.ts +115 -0
- package/dist/internal/tools.d.ts.map +1 -0
- package/dist/internal/tools.js +80 -0
- package/dist/server/attribution.d.ts +39 -0
- package/dist/server/attribution.d.ts.map +1 -0
- package/dist/server/attribution.js +67 -0
- package/dist/server/authorizeUrl.d.ts +43 -0
- package/dist/server/authorizeUrl.d.ts.map +1 -0
- package/dist/server/authorizeUrl.js +110 -0
- package/dist/server/emit.d.ts +63 -0
- package/dist/server/emit.d.ts.map +1 -0
- package/dist/server/emit.js +101 -0
- package/dist/server/everyApp.d.ts +38 -12
- package/dist/server/everyApp.d.ts.map +1 -1
- package/dist/server/everyApp.js +92 -28
- package/dist/server/gatewayFetch.d.ts +24 -8
- package/dist/server/gatewayFetch.d.ts.map +1 -1
- package/dist/server/gatewayFetch.js +81 -36
- package/dist/server/index.d.ts +10 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +8 -1
- package/dist/server/integrationFetch.d.ts +131 -0
- package/dist/server/integrationFetch.d.ts.map +1 -0
- package/dist/server/integrationFetch.js +526 -0
- package/dist/server/listMembers.d.ts +32 -0
- package/dist/server/listMembers.d.ts.map +1 -0
- package/dist/server/listMembers.js +74 -0
- package/dist/server/nodeServer.d.ts +48 -0
- package/dist/server/nodeServer.d.ts.map +1 -0
- package/dist/server/nodeServer.js +45 -0
- package/dist/server/sendEmail.d.ts +39 -0
- package/dist/server/sendEmail.d.ts.map +1 -0
- package/dist/server/sendEmail.js +114 -0
- package/dist/server/tools.d.ts +184 -0
- package/dist/server/tools.d.ts.map +1 -0
- package/dist/server/tools.js +347 -0
- package/dist/server/toolsEndpoint.d.ts +32 -0
- package/dist/server/toolsEndpoint.d.ts.map +1 -0
- package/dist/server/toolsEndpoint.js +231 -0
- package/package.json +30 -17
- package/dist/server/mcp.d.ts +0 -66
- package/dist/server/mcp.d.ts.map +0 -1
- package/dist/server/mcp.js +0 -147
|
@@ -0,0 +1,1521 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in provider catalog: everything the gateway holds a credential
|
|
3
|
+
* for on apps' behalf (docs/capabilities.md,
|
|
4
|
+
* docs/design/2026-10-provider-registry.md), of four kinds, each an entry
|
|
5
|
+
* of data and nothing else, so a provider the catalog doesn't have can be
|
|
6
|
+
* registered on one gateway as a row of the same shape (./providerSpec.ts,
|
|
7
|
+
* ./providerRegistry.ts):
|
|
8
|
+
*
|
|
9
|
+
* - `api_key`: a provider whose key the gateway holds. The operator sets
|
|
10
|
+
* the key once on the gateway (`everyapp secret set <secret>`), an app lists the provider in its manifest's `capabilities`,
|
|
11
|
+
* and the app calls the provider through the AppGateway binding
|
|
12
|
+
* (`integrationFetch()`), so no app ever holds the key. `auth` says
|
|
13
|
+
* where the key goes on the request ({@link ApiKeyAuth}).
|
|
14
|
+
* - `ai`: an AI provider, reached under `providers` in the manifest and
|
|
15
|
+
* `/api/ai/<id>` on the binding (`createGatewayFetch()`), with the AI
|
|
16
|
+
* proxy's rules: inference routes only, the model allowlist, the usage
|
|
17
|
+
* tap. Never a capability: its key would reach the account's credits
|
|
18
|
+
* and keys on the same host.
|
|
19
|
+
* - `oauth_client`: a provider's OAuth client (a social network's), held
|
|
20
|
+
* the same way. The gateway completes the token endpoint calls only,
|
|
21
|
+
* putting the client on them; the app builds the authorize URL from the
|
|
22
|
+
* public client id (`integrationStatus()`), keeps the user tokens and
|
|
23
|
+
* calls the provider's API with them itself. A client shared by apps
|
|
24
|
+
* with different needs carries a `scopePolicy`: named scope sets an app
|
|
25
|
+
* lists in its manifest entry's `scopes`, which ./oauthScopes.ts turns
|
|
26
|
+
* into what the app may ask the provider for and what a token it is
|
|
27
|
+
* handed may carry.
|
|
28
|
+
* - `service`: something the gateway does itself, with no upstream host
|
|
29
|
+
* an app names (sending email, listing the organization's members). An
|
|
30
|
+
* app lists it the same way and calls its routes,
|
|
31
|
+
* `/api/integrations/<id>/<route>`, through the SDK's own helper
|
|
32
|
+
* (`sendEmail()`, `listMembers()`).
|
|
33
|
+
*
|
|
34
|
+
* The gateway enforces these definitions, the manifest validator checks
|
|
35
|
+
* `capabilities` against them, and the SDK reads them for its local-dev
|
|
36
|
+
* fallback, which calls the provider directly with a developer's own key.
|
|
37
|
+
* All three import them from here so they cannot drift.
|
|
38
|
+
*/
|
|
39
|
+
/** What `access: "write"` adds to a provider's read methods when its entry doesn't say. */
|
|
40
|
+
export const DEFAULT_WRITE_METHODS = [
|
|
41
|
+
"POST",
|
|
42
|
+
"PUT",
|
|
43
|
+
"PATCH",
|
|
44
|
+
"DELETE",
|
|
45
|
+
];
|
|
46
|
+
export const CAPABILITIES = {
|
|
47
|
+
// The AI providers, in the order the AI proxy's table had them.
|
|
48
|
+
openai: {
|
|
49
|
+
mode: "ai",
|
|
50
|
+
label: "OpenAI",
|
|
51
|
+
description: "OpenAI's models: responses, chat, embeddings, audio, images.",
|
|
52
|
+
auth: { kind: "bearer", secret: "OPENAI_API_KEY" },
|
|
53
|
+
docs: "https://platform.openai.com/api-keys",
|
|
54
|
+
api: "https://platform.openai.com/docs/api-reference",
|
|
55
|
+
setup: "setup/openai.md",
|
|
56
|
+
origin: "https://api.openai.com",
|
|
57
|
+
inferenceRoutes: [
|
|
58
|
+
"/v1/responses",
|
|
59
|
+
"/v1/chat/completions",
|
|
60
|
+
"/v1/embeddings",
|
|
61
|
+
"/v1/audio/speech",
|
|
62
|
+
"/v1/audio/transcriptions",
|
|
63
|
+
"/v1/audio/translations",
|
|
64
|
+
"/v1/images/generations",
|
|
65
|
+
"/v1/images/edits",
|
|
66
|
+
"/v1/images/variations",
|
|
67
|
+
],
|
|
68
|
+
unstoredRoutes: ["/v1/responses", "/v1/chat/completions"],
|
|
69
|
+
// More than inference needs both the operator (AI_PROXY_EXTRA_ROUTES)
|
|
70
|
+
// and the app's manifest (`ai.routes`): OpenAI paths only.
|
|
71
|
+
extraRoutes: true,
|
|
72
|
+
hint: "A secret key from the API keys page; the Gateway puts it on every call as a bearer token.",
|
|
73
|
+
verified: { on: "2026-10-25", how: "live" },
|
|
74
|
+
},
|
|
75
|
+
// OpenAI-compatible, in front of many model providers. Usage (with cost)
|
|
76
|
+
// comes back in the response, so apps can meter their own spend.
|
|
77
|
+
openrouter: {
|
|
78
|
+
mode: "ai",
|
|
79
|
+
label: "OpenRouter",
|
|
80
|
+
description: "One key for many models, through OpenAI's API shape.",
|
|
81
|
+
auth: { kind: "bearer", secret: "OPENROUTER_API_KEY" },
|
|
82
|
+
docs: "https://openrouter.ai/settings/keys",
|
|
83
|
+
api: "https://openrouter.ai/docs",
|
|
84
|
+
setup: "setup/openrouter.md",
|
|
85
|
+
origin: "https://openrouter.ai",
|
|
86
|
+
inferenceRoutes: [
|
|
87
|
+
"/api/v1/chat/completions",
|
|
88
|
+
"/api/v1/completions",
|
|
89
|
+
"/api/v1/responses",
|
|
90
|
+
"/api/v1/embeddings",
|
|
91
|
+
],
|
|
92
|
+
hint: "An API key from OpenRouter's keys page; the Gateway puts it on every call as a bearer token.",
|
|
93
|
+
verified: { on: "2026-10-25", how: "live" },
|
|
94
|
+
},
|
|
95
|
+
// Workers AI's OpenAI-compatible REST API, in the gateway's own account
|
|
96
|
+
// (`/v1/run/...` and the native `/run/<model>` stay closed). Its chat
|
|
97
|
+
// streams send all-digit tokens as JSON numbers, which the gateway turns
|
|
98
|
+
// back into strings (normalizeWorkersAiResponse).
|
|
99
|
+
"workers-ai": {
|
|
100
|
+
mode: "ai",
|
|
101
|
+
label: "Workers AI",
|
|
102
|
+
description: "Cloudflare's models, in the Gateway's own account.",
|
|
103
|
+
auth: { kind: "bearer", secret: "WORKERS_AI_API_TOKEN" },
|
|
104
|
+
// A token from the "Workers AI" template (Read and Edit).
|
|
105
|
+
docs: "https://dash.cloudflare.com/profile/api-tokens",
|
|
106
|
+
api: "https://developers.cloudflare.com/workers-ai/",
|
|
107
|
+
setup: "setup/workers-ai.md",
|
|
108
|
+
origin: "https://api.cloudflare.com",
|
|
109
|
+
alsoNeeds: ["CLOUDFLARE_ACCOUNT_ID"],
|
|
110
|
+
basePath: "/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai",
|
|
111
|
+
inferenceRoutes: [
|
|
112
|
+
"/v1/chat/completions",
|
|
113
|
+
"/v1/embeddings",
|
|
114
|
+
"/v1/responses",
|
|
115
|
+
],
|
|
116
|
+
normalize: "workers-ai",
|
|
117
|
+
hint: "A Cloudflare API token with Workers AI Read (the Workers AI page's prefilled token); the account is the Gateway's own.",
|
|
118
|
+
verified: { on: "2026-10-25", how: "live" },
|
|
119
|
+
},
|
|
120
|
+
dataforseo: {
|
|
121
|
+
mode: "api_key",
|
|
122
|
+
label: "DataForSEO",
|
|
123
|
+
description: "SEO data: keyword metrics, live search results, domain rankings, backlinks.",
|
|
124
|
+
// The key is `login:password` as the dashboard shows them, or that pair
|
|
125
|
+
// already base64-encoded (what OpenSEO's DATAFORSEO_API_KEY has always held).
|
|
126
|
+
auth: { kind: "basic", secret: "DATAFORSEO_API_KEY", as: "pair" },
|
|
127
|
+
docs: "https://app.dataforseo.com/api-access",
|
|
128
|
+
api: "https://docs.dataforseo.com/v3/",
|
|
129
|
+
setup: "setup/dataforseo.md",
|
|
130
|
+
// The sandbox answers like production, with made-up data and no charge.
|
|
131
|
+
hosts: ["api.dataforseo.com", "sandbox.dataforseo.com"],
|
|
132
|
+
access: ["read"],
|
|
133
|
+
// Every DataForSEO endpoint reads data, the paid live POSTs included;
|
|
134
|
+
// none changes anything the organization owns.
|
|
135
|
+
readMethods: ["GET", "POST"],
|
|
136
|
+
// Results are fetched by task id, which only the app that posted the
|
|
137
|
+
// task holds; these list every task (ready, or fixed within the last
|
|
138
|
+
// week), error and setting on the account.
|
|
139
|
+
accountWidePaths: [
|
|
140
|
+
["tasks_ready"],
|
|
141
|
+
["tasks_fixed"],
|
|
142
|
+
["id_list"],
|
|
143
|
+
["errors"],
|
|
144
|
+
["appendix", "user_data"],
|
|
145
|
+
["appendix", "webhook_resend"],
|
|
146
|
+
],
|
|
147
|
+
hint: "The API login and password from API Access, as login:password (or that pair base64-encoded); the Gateway sends them as HTTP Basic.",
|
|
148
|
+
secrets: [
|
|
149
|
+
{
|
|
150
|
+
name: "DATAFORSEO_API_KEY",
|
|
151
|
+
hint: "login:password, or its base64; never the dashboard sign-in password.",
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
// DataForSEO's envelope starts with the call's cost in USD.
|
|
155
|
+
cost: {
|
|
156
|
+
unit: "call",
|
|
157
|
+
responseField: "cost",
|
|
158
|
+
note: "A Google SERP $0.002 per 10 results; a Labs task $0.012 plus $0.00012 per item.",
|
|
159
|
+
},
|
|
160
|
+
smoke: {
|
|
161
|
+
method: "GET",
|
|
162
|
+
path: "/v3/appendix/user_data",
|
|
163
|
+
expect: [200],
|
|
164
|
+
free: true,
|
|
165
|
+
note: "Account-wide, so the Gateway refuses it for apps; run it from the person's terminal.",
|
|
166
|
+
},
|
|
167
|
+
example: "find keyword ideas for a page about running shoes",
|
|
168
|
+
verified: { on: "2026-10-25", how: "live" },
|
|
169
|
+
},
|
|
170
|
+
context: {
|
|
171
|
+
mode: "api_key",
|
|
172
|
+
label: "Context.dev",
|
|
173
|
+
description: "Scrapes and renders web pages, JavaScript included.",
|
|
174
|
+
auth: { kind: "bearer", secret: "CONTEXT_API_KEY" },
|
|
175
|
+
docs: "https://context.dev/",
|
|
176
|
+
setup: "setup/context.md",
|
|
177
|
+
hosts: ["api.context.dev"],
|
|
178
|
+
access: ["read"],
|
|
179
|
+
// Scraping and rendering pages reads the web; nothing it owns changes.
|
|
180
|
+
readMethods: ["GET", "POST"],
|
|
181
|
+
accountWidePaths: [],
|
|
182
|
+
hint: "An API key from the Context.dev dashboard; the Gateway puts it on every call as a bearer token.",
|
|
183
|
+
verified: { on: "2026-10-25", how: "live" },
|
|
184
|
+
},
|
|
185
|
+
// Outbound mail through the gateway's own sender and transport
|
|
186
|
+
// (apps/every-app-gateway/src/server/capabilities/email.ts): one sending
|
|
187
|
+
// domain onboarded per gateway, its daily budgets, attribution and logs.
|
|
188
|
+
email: {
|
|
189
|
+
mode: "service",
|
|
190
|
+
label: "Email",
|
|
191
|
+
access: ["write"],
|
|
192
|
+
routes: { send: ["POST"] },
|
|
193
|
+
// The sender is EMAIL_FROM with a display name: the gateway refuses to
|
|
194
|
+
// send without one. EMAIL_FROM_NAME is the plain way to give it, so the
|
|
195
|
+
// setup dialog and the CLI name both; EMAIL_FROM="Name <address>" stays
|
|
196
|
+
// accepted (parseEmailSender in internal/email.ts), and the gateway,
|
|
197
|
+
// which reads values, answers by its own rule (isEmailConfigured: a
|
|
198
|
+
// sender that parses and a provider that is selectable) rather than by
|
|
199
|
+
// these names. The CLI reads names alone, so it calls EMAIL_FROM_NAME
|
|
200
|
+
// unset until it is.
|
|
201
|
+
settings: ["EMAIL_FROM", "EMAIL_FROM_NAME"],
|
|
202
|
+
// The providers' settings (internal/email.ts chooseEmailProvider):
|
|
203
|
+
// Cloudflare's REST pair, sent instead of the gateway's EMAIL binding
|
|
204
|
+
// (a gateway outside Workers, local dev); Resend's key; and the choice
|
|
205
|
+
// between them when both are set.
|
|
206
|
+
optionalSettings: [
|
|
207
|
+
"EMAIL_REST_API_TOKEN",
|
|
208
|
+
"CLOUDFLARE_ACCOUNT_ID",
|
|
209
|
+
"RESEND_API_KEY",
|
|
210
|
+
"EMAIL_PROVIDER",
|
|
211
|
+
],
|
|
212
|
+
setup: "setup/email.md",
|
|
213
|
+
},
|
|
214
|
+
// The organization's members who can open the app
|
|
215
|
+
// (apps/every-app-gateway/src/server/capabilities/members.ts): who an app
|
|
216
|
+
// may email or notify now, after people leave or lose access. It needs no
|
|
217
|
+
// setting; the gateway reads its own directory.
|
|
218
|
+
members: {
|
|
219
|
+
mode: "service",
|
|
220
|
+
label: "Members",
|
|
221
|
+
access: ["read"],
|
|
222
|
+
routes: { list: ["GET"] },
|
|
223
|
+
settings: [],
|
|
224
|
+
optionalSettings: [],
|
|
225
|
+
},
|
|
226
|
+
// Social networks' OAuth clients (docs/capabilities.md "OAuth clients"):
|
|
227
|
+
// the gateway completes their token endpoints only. Postiz connects its
|
|
228
|
+
// channels with them; one client serves every organization on the gateway.
|
|
229
|
+
linkedin: {
|
|
230
|
+
mode: "oauth_client",
|
|
231
|
+
label: "LinkedIn",
|
|
232
|
+
secrets: {
|
|
233
|
+
clientId: "LINKEDIN_CLIENT_ID",
|
|
234
|
+
clientSecret: "LINKEDIN_CLIENT_SECRET",
|
|
235
|
+
},
|
|
236
|
+
token: [
|
|
237
|
+
{
|
|
238
|
+
host: "www.linkedin.com",
|
|
239
|
+
path: "/oauth/v2/accessToken",
|
|
240
|
+
methods: ["POST"],
|
|
241
|
+
inject: "form",
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
access: ["write"],
|
|
245
|
+
console: "https://www.linkedin.com/developers/apps",
|
|
246
|
+
authorize: {
|
|
247
|
+
url: "https://www.linkedin.com/oauth/v2/authorization",
|
|
248
|
+
scopes: [
|
|
249
|
+
"openid",
|
|
250
|
+
"profile",
|
|
251
|
+
"w_member_social",
|
|
252
|
+
"r_basicprofile",
|
|
253
|
+
"rw_organization_admin",
|
|
254
|
+
"w_organization_social",
|
|
255
|
+
"r_organization_social",
|
|
256
|
+
],
|
|
257
|
+
},
|
|
258
|
+
api: ["api.linkedin.com"],
|
|
259
|
+
// Profiles and pages: both callbacks complete on the one client.
|
|
260
|
+
redirectPaths: [
|
|
261
|
+
"/integrations/social/linkedin",
|
|
262
|
+
"/integrations/social/linkedin-page",
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
facebook: {
|
|
266
|
+
mode: "oauth_client",
|
|
267
|
+
label: "Facebook",
|
|
268
|
+
secrets: {
|
|
269
|
+
clientId: "FACEBOOK_APP_ID",
|
|
270
|
+
clientSecret: "FACEBOOK_APP_SECRET",
|
|
271
|
+
},
|
|
272
|
+
token: [
|
|
273
|
+
{
|
|
274
|
+
// The code exchange and the long-lived token exchange, both on a
|
|
275
|
+
// versioned path (/v25.0/oauth/access_token).
|
|
276
|
+
host: "graph.facebook.com",
|
|
277
|
+
path: /^\/v\d+\.\d+\/oauth\/access_token$/,
|
|
278
|
+
methods: ["GET", "POST"],
|
|
279
|
+
inject: "query",
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
access: ["write"],
|
|
283
|
+
console: "https://developers.facebook.com/apps",
|
|
284
|
+
authorize: {
|
|
285
|
+
url: "https://www.facebook.com/v25.0/dialog/oauth",
|
|
286
|
+
// Facebook pages, and Instagram business accounts through them.
|
|
287
|
+
scopes: [
|
|
288
|
+
"pages_show_list",
|
|
289
|
+
"business_management",
|
|
290
|
+
"pages_manage_posts",
|
|
291
|
+
"pages_manage_engagement",
|
|
292
|
+
"pages_read_engagement",
|
|
293
|
+
"read_insights",
|
|
294
|
+
"instagram_basic",
|
|
295
|
+
"instagram_content_publish",
|
|
296
|
+
"instagram_manage_comments",
|
|
297
|
+
"instagram_manage_insights",
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
api: ["graph.facebook.com", "graph.instagram.com"],
|
|
301
|
+
redirectPaths: [
|
|
302
|
+
"/integrations/social/facebook",
|
|
303
|
+
"/integrations/social/instagram",
|
|
304
|
+
],
|
|
305
|
+
},
|
|
306
|
+
instagram: {
|
|
307
|
+
mode: "oauth_client",
|
|
308
|
+
label: "Instagram",
|
|
309
|
+
secrets: {
|
|
310
|
+
clientId: "INSTAGRAM_APP_ID",
|
|
311
|
+
clientSecret: "INSTAGRAM_APP_SECRET",
|
|
312
|
+
},
|
|
313
|
+
token: [
|
|
314
|
+
// The code exchange, then the long-lived token exchange.
|
|
315
|
+
{
|
|
316
|
+
host: "api.instagram.com",
|
|
317
|
+
path: "/oauth/access_token",
|
|
318
|
+
methods: ["POST"],
|
|
319
|
+
inject: "form",
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
host: "graph.instagram.com",
|
|
323
|
+
path: "/access_token",
|
|
324
|
+
methods: ["GET"],
|
|
325
|
+
inject: "query",
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
access: ["write"],
|
|
329
|
+
console: "https://developers.facebook.com/apps",
|
|
330
|
+
authorize: {
|
|
331
|
+
url: "https://www.instagram.com/oauth/authorize",
|
|
332
|
+
scopes: [
|
|
333
|
+
"instagram_business_basic",
|
|
334
|
+
"instagram_business_content_publish",
|
|
335
|
+
"instagram_business_manage_comments",
|
|
336
|
+
"instagram_business_manage_insights",
|
|
337
|
+
],
|
|
338
|
+
},
|
|
339
|
+
api: ["graph.instagram.com"],
|
|
340
|
+
redirectPaths: ["/integrations/social/instagram-standalone"],
|
|
341
|
+
},
|
|
342
|
+
threads: {
|
|
343
|
+
mode: "oauth_client",
|
|
344
|
+
label: "Threads",
|
|
345
|
+
secrets: { clientId: "THREADS_APP_ID", clientSecret: "THREADS_APP_SECRET" },
|
|
346
|
+
token: [
|
|
347
|
+
// The code exchange, then the long-lived token exchange.
|
|
348
|
+
{
|
|
349
|
+
host: "graph.threads.net",
|
|
350
|
+
path: "/oauth/access_token",
|
|
351
|
+
methods: ["GET", "POST"],
|
|
352
|
+
inject: "query",
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
host: "graph.threads.net",
|
|
356
|
+
path: "/access_token",
|
|
357
|
+
methods: ["GET"],
|
|
358
|
+
inject: "query",
|
|
359
|
+
},
|
|
360
|
+
],
|
|
361
|
+
access: ["write"],
|
|
362
|
+
console: "https://developers.facebook.com/apps",
|
|
363
|
+
authorize: {
|
|
364
|
+
url: "https://threads.net/oauth/authorize",
|
|
365
|
+
scopes: [
|
|
366
|
+
"threads_basic",
|
|
367
|
+
"threads_content_publish",
|
|
368
|
+
"threads_manage_replies",
|
|
369
|
+
"threads_manage_insights",
|
|
370
|
+
],
|
|
371
|
+
},
|
|
372
|
+
api: ["graph.threads.net"],
|
|
373
|
+
redirectPaths: ["/integrations/social/threads"],
|
|
374
|
+
},
|
|
375
|
+
youtube: {
|
|
376
|
+
mode: "oauth_client",
|
|
377
|
+
label: "YouTube",
|
|
378
|
+
secrets: {
|
|
379
|
+
clientId: "YOUTUBE_CLIENT_ID",
|
|
380
|
+
clientSecret: "YOUTUBE_CLIENT_SECRET",
|
|
381
|
+
},
|
|
382
|
+
token: [
|
|
383
|
+
{
|
|
384
|
+
host: "oauth2.googleapis.com",
|
|
385
|
+
path: "/token",
|
|
386
|
+
methods: ["POST"],
|
|
387
|
+
inject: "form",
|
|
388
|
+
},
|
|
389
|
+
],
|
|
390
|
+
access: ["write"],
|
|
391
|
+
console: "https://console.cloud.google.com/apis/credentials",
|
|
392
|
+
setup: "setup/google.md",
|
|
393
|
+
authorize: {
|
|
394
|
+
url: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
395
|
+
// YouTube channels, and Google Business Profiles on the same client.
|
|
396
|
+
scopes: [
|
|
397
|
+
"https://www.googleapis.com/auth/userinfo.profile",
|
|
398
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
|
399
|
+
"https://www.googleapis.com/auth/youtube",
|
|
400
|
+
"https://www.googleapis.com/auth/youtube.force-ssl",
|
|
401
|
+
"https://www.googleapis.com/auth/youtube.readonly",
|
|
402
|
+
"https://www.googleapis.com/auth/youtube.upload",
|
|
403
|
+
"https://www.googleapis.com/auth/youtubepartner",
|
|
404
|
+
"https://www.googleapis.com/auth/yt-analytics.readonly",
|
|
405
|
+
"https://www.googleapis.com/auth/business.manage",
|
|
406
|
+
],
|
|
407
|
+
},
|
|
408
|
+
api: [
|
|
409
|
+
"www.googleapis.com",
|
|
410
|
+
"youtubeanalytics.googleapis.com",
|
|
411
|
+
"mybusinessaccountmanagement.googleapis.com",
|
|
412
|
+
"mybusinessbusinessinformation.googleapis.com",
|
|
413
|
+
"mybusiness.googleapis.com",
|
|
414
|
+
"businessprofileperformance.googleapis.com",
|
|
415
|
+
],
|
|
416
|
+
redirectPaths: ["/integrations/social/youtube", "/integrations/social/gmb"],
|
|
417
|
+
},
|
|
418
|
+
tiktok: {
|
|
419
|
+
mode: "oauth_client",
|
|
420
|
+
label: "TikTok",
|
|
421
|
+
secrets: {
|
|
422
|
+
clientId: "TIKTOK_CLIENT_ID",
|
|
423
|
+
clientSecret: "TIKTOK_CLIENT_SECRET",
|
|
424
|
+
},
|
|
425
|
+
token: [
|
|
426
|
+
{
|
|
427
|
+
host: "open.tiktokapis.com",
|
|
428
|
+
path: "/v2/oauth/token/",
|
|
429
|
+
methods: ["POST"],
|
|
430
|
+
inject: "form",
|
|
431
|
+
// TikTok calls the client id its client key.
|
|
432
|
+
params: { clientId: "client_key" },
|
|
433
|
+
},
|
|
434
|
+
],
|
|
435
|
+
access: ["write"],
|
|
436
|
+
console: "https://developers.tiktok.com/apps",
|
|
437
|
+
authorize: {
|
|
438
|
+
url: "https://www.tiktok.com/v2/auth/authorize/",
|
|
439
|
+
scopes: [
|
|
440
|
+
"video.list",
|
|
441
|
+
"user.info.basic",
|
|
442
|
+
"video.publish",
|
|
443
|
+
"video.upload",
|
|
444
|
+
"user.info.profile",
|
|
445
|
+
"user.info.stats",
|
|
446
|
+
],
|
|
447
|
+
},
|
|
448
|
+
api: ["open.tiktokapis.com"],
|
|
449
|
+
redirectPaths: ["/integrations/social/tiktok"],
|
|
450
|
+
},
|
|
451
|
+
"tiktok-business": {
|
|
452
|
+
mode: "oauth_client",
|
|
453
|
+
label: "TikTok Business",
|
|
454
|
+
secrets: {
|
|
455
|
+
clientId: "TIKTOK_BUSINESS_CLIENT_ID",
|
|
456
|
+
clientSecret: "TIKTOK_BUSINESS_CLIENT_SECRET",
|
|
457
|
+
},
|
|
458
|
+
token: [
|
|
459
|
+
{
|
|
460
|
+
host: "business-api.tiktok.com",
|
|
461
|
+
path: "/open_api/v1.3/tt_user/oauth2/token/",
|
|
462
|
+
methods: ["POST"],
|
|
463
|
+
inject: "json",
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
host: "business-api.tiktok.com",
|
|
467
|
+
path: "/open_api/v1.3/tt_user/oauth2/refresh_token/",
|
|
468
|
+
methods: ["POST"],
|
|
469
|
+
inject: "json",
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
access: ["write"],
|
|
473
|
+
console: "https://business-api.tiktok.com/portal/apps",
|
|
474
|
+
authorize: {
|
|
475
|
+
url: "https://www.tiktok.com/v2/auth/authorize/",
|
|
476
|
+
scopes: [
|
|
477
|
+
"user.info.basic",
|
|
478
|
+
"user.info.username",
|
|
479
|
+
"user.info.stats",
|
|
480
|
+
"video.list",
|
|
481
|
+
"video.upload",
|
|
482
|
+
"video.publish",
|
|
483
|
+
],
|
|
484
|
+
},
|
|
485
|
+
api: ["business-api.tiktok.com"],
|
|
486
|
+
// TikTok Business wants the trailing slash.
|
|
487
|
+
redirectPaths: ["/integrations/social/tiktok-business/"],
|
|
488
|
+
},
|
|
489
|
+
reddit: {
|
|
490
|
+
mode: "oauth_client",
|
|
491
|
+
label: "Reddit",
|
|
492
|
+
secrets: {
|
|
493
|
+
clientId: "REDDIT_CLIENT_ID",
|
|
494
|
+
clientSecret: "REDDIT_CLIENT_SECRET",
|
|
495
|
+
},
|
|
496
|
+
token: [
|
|
497
|
+
{
|
|
498
|
+
host: "www.reddit.com",
|
|
499
|
+
path: "/api/v1/access_token",
|
|
500
|
+
methods: ["POST"],
|
|
501
|
+
inject: "basic",
|
|
502
|
+
},
|
|
503
|
+
],
|
|
504
|
+
access: ["write"],
|
|
505
|
+
console: "https://www.reddit.com/prefs/apps",
|
|
506
|
+
authorize: {
|
|
507
|
+
url: "https://www.reddit.com/api/v1/authorize",
|
|
508
|
+
scopes: ["read", "identity", "submit", "flair"],
|
|
509
|
+
},
|
|
510
|
+
api: ["oauth.reddit.com"],
|
|
511
|
+
redirectPaths: ["/integrations/social/reddit"],
|
|
512
|
+
},
|
|
513
|
+
pinterest: {
|
|
514
|
+
mode: "oauth_client",
|
|
515
|
+
label: "Pinterest",
|
|
516
|
+
secrets: {
|
|
517
|
+
clientId: "PINTEREST_CLIENT_ID",
|
|
518
|
+
clientSecret: "PINTEREST_CLIENT_SECRET",
|
|
519
|
+
},
|
|
520
|
+
token: [
|
|
521
|
+
{
|
|
522
|
+
host: "api.pinterest.com",
|
|
523
|
+
path: "/v5/oauth/token",
|
|
524
|
+
methods: ["POST"],
|
|
525
|
+
inject: "basic",
|
|
526
|
+
},
|
|
527
|
+
],
|
|
528
|
+
access: ["write"],
|
|
529
|
+
console: "https://developers.pinterest.com/apps/",
|
|
530
|
+
authorize: {
|
|
531
|
+
url: "https://www.pinterest.com/oauth/",
|
|
532
|
+
scopes: [
|
|
533
|
+
"boards:read",
|
|
534
|
+
"boards:write",
|
|
535
|
+
"pins:read",
|
|
536
|
+
"pins:write",
|
|
537
|
+
"user_accounts:read",
|
|
538
|
+
],
|
|
539
|
+
},
|
|
540
|
+
api: ["api.pinterest.com"],
|
|
541
|
+
redirectPaths: ["/integrations/social/pinterest"],
|
|
542
|
+
},
|
|
543
|
+
discord: {
|
|
544
|
+
mode: "oauth_client",
|
|
545
|
+
label: "Discord",
|
|
546
|
+
secrets: {
|
|
547
|
+
clientId: "DISCORD_CLIENT_ID",
|
|
548
|
+
clientSecret: "DISCORD_CLIENT_SECRET",
|
|
549
|
+
},
|
|
550
|
+
token: [
|
|
551
|
+
{
|
|
552
|
+
host: "discord.com",
|
|
553
|
+
path: "/api/oauth2/token",
|
|
554
|
+
methods: ["POST"],
|
|
555
|
+
inject: "basic",
|
|
556
|
+
},
|
|
557
|
+
],
|
|
558
|
+
access: ["write"],
|
|
559
|
+
console: "https://discord.com/developers/applications",
|
|
560
|
+
// The server install; posting uses the bot's token, an app secret.
|
|
561
|
+
authorize: {
|
|
562
|
+
url: "https://discord.com/oauth2/authorize",
|
|
563
|
+
scopes: ["bot", "identify", "guilds"],
|
|
564
|
+
},
|
|
565
|
+
api: ["discord.com"],
|
|
566
|
+
redirectPaths: ["/integrations/social/discord"],
|
|
567
|
+
},
|
|
568
|
+
slack: {
|
|
569
|
+
mode: "oauth_client",
|
|
570
|
+
label: "Slack",
|
|
571
|
+
secrets: {
|
|
572
|
+
clientId: "SLACK_CLIENT_ID",
|
|
573
|
+
clientSecret: "SLACK_CLIENT_SECRET",
|
|
574
|
+
},
|
|
575
|
+
token: [
|
|
576
|
+
{
|
|
577
|
+
host: "slack.com",
|
|
578
|
+
path: "/api/oauth.v2.access",
|
|
579
|
+
methods: ["POST"],
|
|
580
|
+
inject: "form",
|
|
581
|
+
},
|
|
582
|
+
],
|
|
583
|
+
access: ["write"],
|
|
584
|
+
console: "https://api.slack.com/apps",
|
|
585
|
+
setup: "setup/slack.md",
|
|
586
|
+
authorize: {
|
|
587
|
+
url: "https://slack.com/oauth/v2/authorize",
|
|
588
|
+
// The sets' union: the bot scopes in set order, then the one user
|
|
589
|
+
// scope no bot set carries. Postiz lists `channels.read`,
|
|
590
|
+
// `users.read`, `messages.write`, `channels.join` and `files.write`
|
|
591
|
+
// (it posts to channels it joins and uploads images); Open CRM adds
|
|
592
|
+
// `users.email`, `channels.manage` and `user-channels.manage` (its
|
|
593
|
+
// team agents match members to Slack users by email, create and
|
|
594
|
+
// manage the channels they post to, open DMs, and the installing
|
|
595
|
+
// person invites the bot to private channels).
|
|
596
|
+
scopes: [
|
|
597
|
+
"channels:read",
|
|
598
|
+
"groups:read",
|
|
599
|
+
"users:read",
|
|
600
|
+
"users:read.email",
|
|
601
|
+
"chat:write",
|
|
602
|
+
"chat:write.public",
|
|
603
|
+
"chat:write.customize",
|
|
604
|
+
"channels:join",
|
|
605
|
+
"channels:manage",
|
|
606
|
+
"groups:write",
|
|
607
|
+
"channels:write.invites",
|
|
608
|
+
"groups:write.invites",
|
|
609
|
+
"im:write",
|
|
610
|
+
"files:write",
|
|
611
|
+
"channels:write",
|
|
612
|
+
],
|
|
613
|
+
},
|
|
614
|
+
scopePolicy: {
|
|
615
|
+
dialect: "slack",
|
|
616
|
+
// `oauth.v2.access` takes `code` with no `grant_type`; an app that
|
|
617
|
+
// opts into token rotation refreshes with `refresh_token`.
|
|
618
|
+
grantTypes: [null, "refresh_token"],
|
|
619
|
+
// Slack reads no `scope` on the token leg (the installation's scopes
|
|
620
|
+
// were fixed on the authorize URL), so the request goes as sent.
|
|
621
|
+
requestScope: "keep",
|
|
622
|
+
alwaysRequested: [],
|
|
623
|
+
tolerated: [],
|
|
624
|
+
deniedSegments: [],
|
|
625
|
+
// Slack authorizes by method name, not verb, and every method is one
|
|
626
|
+
// exact `/api/<method>` path on the host that also serves the token
|
|
627
|
+
// endpoint (which no set lists). A read set names its methods in
|
|
628
|
+
// `paths` (GET) and `posts` (POST) alike; a write set in `posts`
|
|
629
|
+
// only, with the one listing among them in `paths` too.
|
|
630
|
+
sets: {
|
|
631
|
+
"channels.read": {
|
|
632
|
+
label: "Channels (read-only)",
|
|
633
|
+
access: "read",
|
|
634
|
+
scopes: ["channels:read", "groups:read"],
|
|
635
|
+
routes: [
|
|
636
|
+
{
|
|
637
|
+
host: "slack.com",
|
|
638
|
+
paths: [
|
|
639
|
+
"/api/conversations.list",
|
|
640
|
+
"/api/conversations.info",
|
|
641
|
+
"/api/conversations.members",
|
|
642
|
+
"/api/users.conversations",
|
|
643
|
+
],
|
|
644
|
+
posts: [
|
|
645
|
+
/^\/api\/conversations\.(list|info|members)$/,
|
|
646
|
+
/^\/api\/users\.conversations$/,
|
|
647
|
+
],
|
|
648
|
+
},
|
|
649
|
+
],
|
|
650
|
+
},
|
|
651
|
+
"users.read": {
|
|
652
|
+
label: "Members (read-only)",
|
|
653
|
+
access: "read",
|
|
654
|
+
scopes: ["users:read"],
|
|
655
|
+
routes: [
|
|
656
|
+
{
|
|
657
|
+
host: "slack.com",
|
|
658
|
+
paths: [
|
|
659
|
+
"/api/users.list",
|
|
660
|
+
"/api/users.info",
|
|
661
|
+
"/api/users.getPresence",
|
|
662
|
+
"/api/bots.info",
|
|
663
|
+
],
|
|
664
|
+
posts: [
|
|
665
|
+
/^\/api\/users\.(list|info|getPresence)$/,
|
|
666
|
+
/^\/api\/bots\.info$/,
|
|
667
|
+
],
|
|
668
|
+
},
|
|
669
|
+
],
|
|
670
|
+
},
|
|
671
|
+
"users.email": {
|
|
672
|
+
// Slack grants it only beside `users:read`: declare both sets.
|
|
673
|
+
label: "Members' email addresses (read-only)",
|
|
674
|
+
access: "read",
|
|
675
|
+
scopes: ["users:read.email"],
|
|
676
|
+
routes: [
|
|
677
|
+
{
|
|
678
|
+
host: "slack.com",
|
|
679
|
+
paths: ["/api/users.lookupByEmail"],
|
|
680
|
+
posts: [/^\/api\/users\.lookupByEmail$/],
|
|
681
|
+
},
|
|
682
|
+
],
|
|
683
|
+
},
|
|
684
|
+
"messages.write": {
|
|
685
|
+
label: "Messages (post as the app)",
|
|
686
|
+
access: "write",
|
|
687
|
+
scopes: ["chat:write", "chat:write.public", "chat:write.customize"],
|
|
688
|
+
routes: [
|
|
689
|
+
{
|
|
690
|
+
host: "slack.com",
|
|
691
|
+
// The scheduled-messages listing is the one read among them.
|
|
692
|
+
paths: ["/api/chat.scheduledMessages.list"],
|
|
693
|
+
posts: [
|
|
694
|
+
/^\/api\/chat\.(postMessage|postEphemeral|update|delete|scheduleMessage|deleteScheduledMessage|scheduledMessages\.list)$/,
|
|
695
|
+
],
|
|
696
|
+
},
|
|
697
|
+
],
|
|
698
|
+
},
|
|
699
|
+
"channels.join": {
|
|
700
|
+
label: "Channels (join public ones)",
|
|
701
|
+
access: "write",
|
|
702
|
+
scopes: ["channels:join"],
|
|
703
|
+
routes: [
|
|
704
|
+
{
|
|
705
|
+
host: "slack.com",
|
|
706
|
+
paths: [],
|
|
707
|
+
posts: [/^\/api\/conversations\.join$/],
|
|
708
|
+
},
|
|
709
|
+
],
|
|
710
|
+
},
|
|
711
|
+
"channels.manage": {
|
|
712
|
+
label: "Channels (create, manage and invite to; open DMs)",
|
|
713
|
+
access: "write",
|
|
714
|
+
scopes: [
|
|
715
|
+
"channels:manage",
|
|
716
|
+
"groups:write",
|
|
717
|
+
"channels:write.invites",
|
|
718
|
+
"groups:write.invites",
|
|
719
|
+
"im:write",
|
|
720
|
+
],
|
|
721
|
+
routes: [
|
|
722
|
+
{
|
|
723
|
+
host: "slack.com",
|
|
724
|
+
paths: [],
|
|
725
|
+
posts: [
|
|
726
|
+
/^\/api\/conversations\.(create|rename|archive|unarchive|invite|open|kick|leave|setTopic|setPurpose|mark)$/,
|
|
727
|
+
],
|
|
728
|
+
},
|
|
729
|
+
],
|
|
730
|
+
},
|
|
731
|
+
"files.write": {
|
|
732
|
+
label: "Files (upload)",
|
|
733
|
+
access: "write",
|
|
734
|
+
scopes: ["files:write"],
|
|
735
|
+
routes: [
|
|
736
|
+
{
|
|
737
|
+
host: "slack.com",
|
|
738
|
+
paths: [],
|
|
739
|
+
// The external upload flow; the retired `files.upload` stays off.
|
|
740
|
+
posts: [
|
|
741
|
+
/^\/api\/files\.(getUploadURLExternal|completeUploadExternal)$/,
|
|
742
|
+
],
|
|
743
|
+
},
|
|
744
|
+
],
|
|
745
|
+
},
|
|
746
|
+
"user-channels.manage": {
|
|
747
|
+
label: "Channels (as the installing person: read, manage, invite to)",
|
|
748
|
+
access: "write",
|
|
749
|
+
scopes: [],
|
|
750
|
+
// Slack's `user_scope`: issued to the person's own token, which
|
|
751
|
+
// Open CRM uses to invite the bot to private channels.
|
|
752
|
+
userScopes: [
|
|
753
|
+
"channels:read",
|
|
754
|
+
"channels:write",
|
|
755
|
+
"groups:read",
|
|
756
|
+
"groups:write",
|
|
757
|
+
],
|
|
758
|
+
routes: [
|
|
759
|
+
{
|
|
760
|
+
host: "slack.com",
|
|
761
|
+
paths: [
|
|
762
|
+
"/api/conversations.list",
|
|
763
|
+
"/api/conversations.info",
|
|
764
|
+
"/api/conversations.members",
|
|
765
|
+
"/api/users.conversations",
|
|
766
|
+
],
|
|
767
|
+
posts: [
|
|
768
|
+
/^\/api\/conversations\.(list|info|members|create|rename|archive|unarchive|invite|kick|leave|setTopic|setPurpose|mark)$/,
|
|
769
|
+
/^\/api\/users\.conversations$/,
|
|
770
|
+
],
|
|
771
|
+
},
|
|
772
|
+
],
|
|
773
|
+
},
|
|
774
|
+
},
|
|
775
|
+
},
|
|
776
|
+
api: ["slack.com"],
|
|
777
|
+
redirectPaths: [
|
|
778
|
+
"/integrations/social/slack",
|
|
779
|
+
"/integrations/slack/callback",
|
|
780
|
+
],
|
|
781
|
+
},
|
|
782
|
+
mastodon: {
|
|
783
|
+
mode: "oauth_client",
|
|
784
|
+
label: "Mastodon",
|
|
785
|
+
secrets: {
|
|
786
|
+
clientId: "MASTODON_CLIENT_ID",
|
|
787
|
+
clientSecret: "MASTODON_CLIENT_SECRET",
|
|
788
|
+
},
|
|
789
|
+
// On the instance the operator registered the client on.
|
|
790
|
+
token: [{ path: "/oauth/token", methods: ["POST"], inject: "form" }],
|
|
791
|
+
hostSetting: "MASTODON_URL",
|
|
792
|
+
defaultOrigin: "https://mastodon.social",
|
|
793
|
+
access: ["write"],
|
|
794
|
+
console: "/settings/applications",
|
|
795
|
+
authorize: {
|
|
796
|
+
url: "/oauth/authorize",
|
|
797
|
+
scopes: ["write:statuses", "profile", "write:media"],
|
|
798
|
+
},
|
|
799
|
+
api: [],
|
|
800
|
+
redirectPaths: ["/integrations/social/mastodon"],
|
|
801
|
+
},
|
|
802
|
+
kick: {
|
|
803
|
+
mode: "oauth_client",
|
|
804
|
+
label: "Kick",
|
|
805
|
+
secrets: { clientId: "KICK_CLIENT_ID", clientSecret: "KICK_CLIENT_SECRET" },
|
|
806
|
+
token: [
|
|
807
|
+
{
|
|
808
|
+
host: "id.kick.com",
|
|
809
|
+
path: "/oauth/token",
|
|
810
|
+
methods: ["POST"],
|
|
811
|
+
inject: "form",
|
|
812
|
+
},
|
|
813
|
+
],
|
|
814
|
+
access: ["write"],
|
|
815
|
+
console: "https://kick.com/settings/developer",
|
|
816
|
+
authorize: {
|
|
817
|
+
url: "https://id.kick.com/oauth/authorize",
|
|
818
|
+
scopes: ["chat:write", "user:read", "channel:read"],
|
|
819
|
+
},
|
|
820
|
+
api: ["api.kick.com"],
|
|
821
|
+
redirectPaths: ["/integrations/social/kick"],
|
|
822
|
+
},
|
|
823
|
+
twitch: {
|
|
824
|
+
mode: "oauth_client",
|
|
825
|
+
label: "Twitch",
|
|
826
|
+
secrets: {
|
|
827
|
+
clientId: "TWITCH_CLIENT_ID",
|
|
828
|
+
clientSecret: "TWITCH_CLIENT_SECRET",
|
|
829
|
+
},
|
|
830
|
+
token: [
|
|
831
|
+
{
|
|
832
|
+
host: "id.twitch.tv",
|
|
833
|
+
path: "/oauth2/token",
|
|
834
|
+
methods: ["POST"],
|
|
835
|
+
inject: "form",
|
|
836
|
+
},
|
|
837
|
+
],
|
|
838
|
+
access: ["write"],
|
|
839
|
+
console: "https://dev.twitch.tv/console/apps",
|
|
840
|
+
authorize: {
|
|
841
|
+
url: "https://id.twitch.tv/oauth2/authorize",
|
|
842
|
+
scopes: [
|
|
843
|
+
"user:write:chat",
|
|
844
|
+
"user:read:chat",
|
|
845
|
+
"moderator:manage:announcements",
|
|
846
|
+
],
|
|
847
|
+
},
|
|
848
|
+
// Helix also wants the public client id, as Client-Id, on every call.
|
|
849
|
+
api: ["api.twitch.tv"],
|
|
850
|
+
redirectPaths: ["/integrations/social/twitch"],
|
|
851
|
+
},
|
|
852
|
+
tumblr: {
|
|
853
|
+
mode: "oauth_client",
|
|
854
|
+
label: "Tumblr",
|
|
855
|
+
secrets: {
|
|
856
|
+
clientId: "TUMBLR_CLIENT_ID",
|
|
857
|
+
clientSecret: "TUMBLR_CLIENT_SECRET",
|
|
858
|
+
},
|
|
859
|
+
token: [
|
|
860
|
+
{
|
|
861
|
+
host: "api.tumblr.com",
|
|
862
|
+
path: "/v2/oauth2/token",
|
|
863
|
+
methods: ["POST"],
|
|
864
|
+
inject: "form",
|
|
865
|
+
},
|
|
866
|
+
],
|
|
867
|
+
access: ["write"],
|
|
868
|
+
console: "https://www.tumblr.com/oauth/apps",
|
|
869
|
+
authorize: {
|
|
870
|
+
url: "https://www.tumblr.com/oauth2/authorize",
|
|
871
|
+
scopes: ["write", "offline_access"],
|
|
872
|
+
},
|
|
873
|
+
api: ["api.tumblr.com"],
|
|
874
|
+
redirectPaths: ["/integrations/social/tumblr"],
|
|
875
|
+
},
|
|
876
|
+
dribbble: {
|
|
877
|
+
mode: "oauth_client",
|
|
878
|
+
label: "Dribbble",
|
|
879
|
+
secrets: {
|
|
880
|
+
clientId: "DRIBBBLE_CLIENT_ID",
|
|
881
|
+
clientSecret: "DRIBBBLE_CLIENT_SECRET",
|
|
882
|
+
},
|
|
883
|
+
token: [
|
|
884
|
+
{
|
|
885
|
+
// A POST that takes the client in its query string.
|
|
886
|
+
host: "dribbble.com",
|
|
887
|
+
path: "/oauth/token",
|
|
888
|
+
methods: ["POST"],
|
|
889
|
+
inject: "query",
|
|
890
|
+
},
|
|
891
|
+
],
|
|
892
|
+
access: ["write"],
|
|
893
|
+
console: "https://dribbble.com/account/applications",
|
|
894
|
+
authorize: {
|
|
895
|
+
url: "https://dribbble.com/oauth/authorize",
|
|
896
|
+
scopes: ["public", "upload"],
|
|
897
|
+
},
|
|
898
|
+
api: ["api.dribbble.com"],
|
|
899
|
+
redirectPaths: ["/integrations/social/dribbble"],
|
|
900
|
+
},
|
|
901
|
+
// Clients several apps share with different needs, so each app lists the
|
|
902
|
+
// scope sets it uses: Open CRM reads a member's own mailbox and calendar
|
|
903
|
+
// (`gmail.read`, `calendar.read`; `identity`, `mail.read`), OpenSEO reads
|
|
904
|
+
// a site's Search Console and Analytics (`identity`, `search-console.read`,
|
|
905
|
+
// `analytics.read`). Every set is read-only. Google's is one Cloud project
|
|
906
|
+
// apart from YouTube's; Gmail's read scope is restricted and needs
|
|
907
|
+
// Google's verification, and whether Search Console and Analytics share
|
|
908
|
+
// that consent screen with it is the operator's choice when verifying.
|
|
909
|
+
"google-workspace": {
|
|
910
|
+
mode: "oauth_client",
|
|
911
|
+
label: "Google (Gmail, Calendar, Search Console and Analytics)",
|
|
912
|
+
secrets: {
|
|
913
|
+
clientId: "GOOGLE_WORKSPACE_CLIENT_ID",
|
|
914
|
+
clientSecret: "GOOGLE_WORKSPACE_CLIENT_SECRET",
|
|
915
|
+
},
|
|
916
|
+
token: [
|
|
917
|
+
{
|
|
918
|
+
host: "oauth2.googleapis.com",
|
|
919
|
+
path: "/token",
|
|
920
|
+
methods: ["POST"],
|
|
921
|
+
inject: "form",
|
|
922
|
+
},
|
|
923
|
+
],
|
|
924
|
+
access: ["write"],
|
|
925
|
+
console: "https://console.cloud.google.com/apis/credentials",
|
|
926
|
+
setup: "setup/google.md",
|
|
927
|
+
authorize: {
|
|
928
|
+
url: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
929
|
+
scopes: [
|
|
930
|
+
"openid",
|
|
931
|
+
"email",
|
|
932
|
+
"profile",
|
|
933
|
+
"https://www.googleapis.com/auth/gmail.readonly",
|
|
934
|
+
"https://www.googleapis.com/auth/calendar.readonly",
|
|
935
|
+
"https://www.googleapis.com/auth/webmasters.readonly",
|
|
936
|
+
"https://www.googleapis.com/auth/analytics.readonly",
|
|
937
|
+
],
|
|
938
|
+
},
|
|
939
|
+
scopePolicy: {
|
|
940
|
+
dialect: "google",
|
|
941
|
+
// Google documents no scope on either token leg: the response's
|
|
942
|
+
// `scope` is where the gateway learns what a token holds.
|
|
943
|
+
grantTypes: ["authorization_code", "refresh_token"],
|
|
944
|
+
requestScope: "drop",
|
|
945
|
+
alwaysRequested: [],
|
|
946
|
+
// Both spellings: Google documents `email`/`profile` and the userinfo
|
|
947
|
+
// URLs as the same grants, and echoes the URLs.
|
|
948
|
+
tolerated: [
|
|
949
|
+
"openid",
|
|
950
|
+
"email",
|
|
951
|
+
"profile",
|
|
952
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
|
953
|
+
"https://www.googleapis.com/auth/userinfo.profile",
|
|
954
|
+
],
|
|
955
|
+
// Gmail's and Calendar's `/batch/<api>/<version>` carry whole requests
|
|
956
|
+
// inside; Analytics' measurement protocol secrets are readable under
|
|
957
|
+
// the read-only scope.
|
|
958
|
+
deniedSegments: ["batch", "measurementProtocolSecrets"],
|
|
959
|
+
sets: {
|
|
960
|
+
identity: {
|
|
961
|
+
label: "Google account (who you are)",
|
|
962
|
+
access: "read",
|
|
963
|
+
scopes: ["openid", "email", "profile"],
|
|
964
|
+
routes: [
|
|
965
|
+
{ host: "openidconnect.googleapis.com", paths: ["/v1/userinfo"] },
|
|
966
|
+
{
|
|
967
|
+
host: "www.googleapis.com",
|
|
968
|
+
paths: [
|
|
969
|
+
"/oauth2/v2/userinfo",
|
|
970
|
+
"/oauth2/v3/userinfo",
|
|
971
|
+
"/userinfo/v2/me",
|
|
972
|
+
],
|
|
973
|
+
},
|
|
974
|
+
],
|
|
975
|
+
},
|
|
976
|
+
"gmail.read": {
|
|
977
|
+
label: "Gmail (read-only)",
|
|
978
|
+
access: "read",
|
|
979
|
+
scopes: ["https://www.googleapis.com/auth/gmail.readonly"],
|
|
980
|
+
routes: [
|
|
981
|
+
{
|
|
982
|
+
host: "gmail.googleapis.com",
|
|
983
|
+
paths: ["/gmail/v1/users/"],
|
|
984
|
+
// Push notifications start and stop; `messages/send`, drafts
|
|
985
|
+
// and `modify` are the other POSTs under the same prefix.
|
|
986
|
+
posts: [/^\/gmail\/v1\/users\/[^/]+\/(watch|stop)$/],
|
|
987
|
+
},
|
|
988
|
+
],
|
|
989
|
+
},
|
|
990
|
+
"calendar.read": {
|
|
991
|
+
label: "Google Calendar (read-only)",
|
|
992
|
+
access: "read",
|
|
993
|
+
scopes: ["https://www.googleapis.com/auth/calendar.readonly"],
|
|
994
|
+
routes: [
|
|
995
|
+
{
|
|
996
|
+
host: "www.googleapis.com",
|
|
997
|
+
paths: ["/calendar/v3/"],
|
|
998
|
+
posts: [
|
|
999
|
+
/^\/calendar\/v3\/freeBusy$/,
|
|
1000
|
+
/^\/calendar\/v3\/calendars\/[^/]+\/events\/watch$/,
|
|
1001
|
+
/^\/calendar\/v3\/users\/me\/calendarList\/watch$/,
|
|
1002
|
+
/^\/calendar\/v3\/channels\/stop$/,
|
|
1003
|
+
],
|
|
1004
|
+
},
|
|
1005
|
+
],
|
|
1006
|
+
},
|
|
1007
|
+
"search-console.read": {
|
|
1008
|
+
label: "Search Console (read-only)",
|
|
1009
|
+
access: "read",
|
|
1010
|
+
scopes: ["https://www.googleapis.com/auth/webmasters.readonly"],
|
|
1011
|
+
// The v3 paths answer on both hosts; URL inspection is v1 only.
|
|
1012
|
+
// A site is one segment, `https%3A%2F%2Fexample.com%2F` or
|
|
1013
|
+
// `sc-domain%3Aexample.com`, which is why routes match the raw path.
|
|
1014
|
+
routes: [
|
|
1015
|
+
{
|
|
1016
|
+
host: "www.googleapis.com",
|
|
1017
|
+
paths: ["/webmasters/v3/"],
|
|
1018
|
+
posts: [
|
|
1019
|
+
/^\/webmasters\/v3\/sites\/[^/]+\/searchAnalytics\/query$/,
|
|
1020
|
+
],
|
|
1021
|
+
},
|
|
1022
|
+
{
|
|
1023
|
+
host: "searchconsole.googleapis.com",
|
|
1024
|
+
paths: ["/webmasters/v3/"],
|
|
1025
|
+
posts: [
|
|
1026
|
+
/^\/webmasters\/v3\/sites\/[^/]+\/searchAnalytics\/query$/,
|
|
1027
|
+
/^\/v1\/urlInspection\/index:inspect$/,
|
|
1028
|
+
],
|
|
1029
|
+
},
|
|
1030
|
+
],
|
|
1031
|
+
},
|
|
1032
|
+
"analytics.read": {
|
|
1033
|
+
label: "Google Analytics (read-only)",
|
|
1034
|
+
access: "read",
|
|
1035
|
+
scopes: ["https://www.googleapis.com/auth/analytics.readonly"],
|
|
1036
|
+
routes: [
|
|
1037
|
+
{
|
|
1038
|
+
// Reports are POSTed queries.
|
|
1039
|
+
host: "analyticsdata.googleapis.com",
|
|
1040
|
+
paths: ["/v1beta/properties/"],
|
|
1041
|
+
posts: [
|
|
1042
|
+
/^\/v1beta\/properties\/[^/]+:(runReport|batchRunReports|runPivotReport|batchRunPivotReports|runRealtimeReport|checkCompatibility)$/,
|
|
1043
|
+
],
|
|
1044
|
+
},
|
|
1045
|
+
{
|
|
1046
|
+
// Reads only: accounts, properties and what hangs off them
|
|
1047
|
+
// (data streams, key events, enhanced measurement settings).
|
|
1048
|
+
host: "analyticsadmin.googleapis.com",
|
|
1049
|
+
paths: [
|
|
1050
|
+
"/v1beta/accountSummaries",
|
|
1051
|
+
"/v1beta/accounts",
|
|
1052
|
+
"/v1beta/accounts/",
|
|
1053
|
+
"/v1beta/properties",
|
|
1054
|
+
"/v1beta/properties/",
|
|
1055
|
+
"/v1alpha/properties/",
|
|
1056
|
+
],
|
|
1057
|
+
},
|
|
1058
|
+
],
|
|
1059
|
+
},
|
|
1060
|
+
},
|
|
1061
|
+
},
|
|
1062
|
+
api: [
|
|
1063
|
+
"gmail.googleapis.com",
|
|
1064
|
+
"www.googleapis.com",
|
|
1065
|
+
"searchconsole.googleapis.com",
|
|
1066
|
+
"analyticsdata.googleapis.com",
|
|
1067
|
+
"analyticsadmin.googleapis.com",
|
|
1068
|
+
"openidconnect.googleapis.com",
|
|
1069
|
+
],
|
|
1070
|
+
redirectPaths: ["/integrations/google/callback"],
|
|
1071
|
+
},
|
|
1072
|
+
"microsoft-365": {
|
|
1073
|
+
mode: "oauth_client",
|
|
1074
|
+
label: "Microsoft 365 (Outlook mail and calendar)",
|
|
1075
|
+
secrets: {
|
|
1076
|
+
clientId: "MICROSOFT_365_CLIENT_ID",
|
|
1077
|
+
clientSecret: "MICROSOFT_365_CLIENT_SECRET",
|
|
1078
|
+
},
|
|
1079
|
+
token: [
|
|
1080
|
+
{
|
|
1081
|
+
// The multi-tenant endpoint: register the client for accounts in
|
|
1082
|
+
// any organization and personal accounts. A single-tenant client
|
|
1083
|
+
// answers only on its tenant's path, which this does not complete.
|
|
1084
|
+
host: "login.microsoftonline.com",
|
|
1085
|
+
path: "/common/oauth2/v2.0/token",
|
|
1086
|
+
methods: ["POST"],
|
|
1087
|
+
inject: "form",
|
|
1088
|
+
},
|
|
1089
|
+
],
|
|
1090
|
+
access: ["write"],
|
|
1091
|
+
console: "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
|
|
1092
|
+
setup: "setup/microsoft-365.md",
|
|
1093
|
+
authorize: {
|
|
1094
|
+
url: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
|
1095
|
+
scopes: ["offline_access", "User.Read", "Mail.Read", "Calendars.Read"],
|
|
1096
|
+
},
|
|
1097
|
+
scopePolicy: {
|
|
1098
|
+
dialect: "microsoft",
|
|
1099
|
+
grantTypes: ["authorization_code", "refresh_token"],
|
|
1100
|
+
// The token leg's `scope` is Microsoft's extension for naming the
|
|
1101
|
+
// resource; the gateway sets it to the app's list so a token covers
|
|
1102
|
+
// Graph alone.
|
|
1103
|
+
requestScope: "replace",
|
|
1104
|
+
// Refresh tokens; Entra never echoes it in a response's `scope`.
|
|
1105
|
+
alwaysRequested: ["offline_access"],
|
|
1106
|
+
// Compared case-insensitively, as Microsoft spells them both ways.
|
|
1107
|
+
tolerated: ["openid", "profile", "email", "offline_access", "User.Read"],
|
|
1108
|
+
// `$batch` carries whole requests inside; `beta` is another API.
|
|
1109
|
+
deniedSegments: ["$batch", "beta"],
|
|
1110
|
+
// `/v1.0/me/…` only: `/v1.0/users/<id>/…` reaches shared mailboxes
|
|
1111
|
+
// and calendars under the same permissions.
|
|
1112
|
+
sets: {
|
|
1113
|
+
identity: {
|
|
1114
|
+
label: "Microsoft account (who you are)",
|
|
1115
|
+
access: "read",
|
|
1116
|
+
scopes: ["User.Read"],
|
|
1117
|
+
routes: [
|
|
1118
|
+
{
|
|
1119
|
+
host: "graph.microsoft.com",
|
|
1120
|
+
paths: ["/v1.0/me", "/oidc/userinfo"],
|
|
1121
|
+
},
|
|
1122
|
+
],
|
|
1123
|
+
},
|
|
1124
|
+
"mail.read": {
|
|
1125
|
+
label: "Outlook mail (read-only)",
|
|
1126
|
+
access: "read",
|
|
1127
|
+
scopes: ["Mail.Read"],
|
|
1128
|
+
routes: [
|
|
1129
|
+
{
|
|
1130
|
+
host: "graph.microsoft.com",
|
|
1131
|
+
paths: [
|
|
1132
|
+
"/v1.0/me/messages",
|
|
1133
|
+
"/v1.0/me/messages/",
|
|
1134
|
+
"/v1.0/me/mailFolders",
|
|
1135
|
+
"/v1.0/me/mailFolders/",
|
|
1136
|
+
],
|
|
1137
|
+
},
|
|
1138
|
+
],
|
|
1139
|
+
},
|
|
1140
|
+
"calendar.read": {
|
|
1141
|
+
label: "Outlook calendar (read-only)",
|
|
1142
|
+
access: "read",
|
|
1143
|
+
scopes: ["Calendars.Read"],
|
|
1144
|
+
routes: [
|
|
1145
|
+
{
|
|
1146
|
+
host: "graph.microsoft.com",
|
|
1147
|
+
paths: [
|
|
1148
|
+
"/v1.0/me/calendar",
|
|
1149
|
+
"/v1.0/me/calendar/",
|
|
1150
|
+
"/v1.0/me/calendars",
|
|
1151
|
+
"/v1.0/me/calendars/",
|
|
1152
|
+
"/v1.0/me/events",
|
|
1153
|
+
"/v1.0/me/events/",
|
|
1154
|
+
"/v1.0/me/calendarView",
|
|
1155
|
+
"/v1.0/me/calendarView/",
|
|
1156
|
+
],
|
|
1157
|
+
// Free/busy is a POSTed query.
|
|
1158
|
+
posts: [/^\/v1\.0\/me\/calendar\/getSchedule$/],
|
|
1159
|
+
},
|
|
1160
|
+
],
|
|
1161
|
+
},
|
|
1162
|
+
},
|
|
1163
|
+
},
|
|
1164
|
+
api: ["graph.microsoft.com"],
|
|
1165
|
+
redirectPaths: ["/integrations/microsoft/callback"],
|
|
1166
|
+
},
|
|
1167
|
+
};
|
|
1168
|
+
/** The Gateway settings an API-key or AI provider's `auth` names: the key first, then a Basic username held as a setting. */
|
|
1169
|
+
export function authSettingNames(auth) {
|
|
1170
|
+
const names = [auth.secret];
|
|
1171
|
+
if (auth.kind === "basic" &&
|
|
1172
|
+
auth.as === "password" &&
|
|
1173
|
+
typeof auth.user === "object") {
|
|
1174
|
+
names.push(auth.user.secret);
|
|
1175
|
+
}
|
|
1176
|
+
return names;
|
|
1177
|
+
}
|
|
1178
|
+
/** Every gateway setting `capability` reads, the ones it needs first. */
|
|
1179
|
+
export function capabilitySettings(capability) {
|
|
1180
|
+
switch (capability.mode) {
|
|
1181
|
+
case "api_key":
|
|
1182
|
+
return [
|
|
1183
|
+
...authSettingNames(capability.auth),
|
|
1184
|
+
...(capability.hostSetting ? [capability.hostSetting] : []),
|
|
1185
|
+
];
|
|
1186
|
+
case "ai":
|
|
1187
|
+
return [capability.auth.secret, ...(capability.alsoNeeds ?? [])];
|
|
1188
|
+
case "service":
|
|
1189
|
+
return [...capability.settings, ...capability.optionalSettings];
|
|
1190
|
+
case "oauth_client":
|
|
1191
|
+
return [
|
|
1192
|
+
capability.secrets.clientId,
|
|
1193
|
+
...(capability.secrets.clientSecret
|
|
1194
|
+
? [capability.secrets.clientSecret]
|
|
1195
|
+
: []),
|
|
1196
|
+
...(capability.hostSetting ? [capability.hostSetting] : []),
|
|
1197
|
+
];
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
export const CAPABILITY_IDS = Object.keys(CAPABILITIES);
|
|
1201
|
+
export function isCapabilityId(value) {
|
|
1202
|
+
return typeof value === "string" && Object.hasOwn(CAPABILITIES, value);
|
|
1203
|
+
}
|
|
1204
|
+
/** The built-in catalog as a lookup: what every surface reads without a gateway's rows. */
|
|
1205
|
+
export const CATALOG = {
|
|
1206
|
+
get: (id) => (isCapabilityId(id) ? CAPABILITIES[id] : undefined),
|
|
1207
|
+
ids: () => CAPABILITY_IDS,
|
|
1208
|
+
};
|
|
1209
|
+
/** The ids an app may list under `capabilities`: every provider but the AI ones, which go under `providers`. */
|
|
1210
|
+
export function capabilityIdsOf(lookup) {
|
|
1211
|
+
return lookup.ids().filter((id) => lookup.get(id)?.mode !== "ai");
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* The settings `capability` needs on a request, read from the Gateway's
|
|
1215
|
+
* settings (or `.dev.vars`): the headers to set and the query parameters
|
|
1216
|
+
* to add, or null while any setting its `auth` names is unset or blank.
|
|
1217
|
+
* Values are trimmed. A Basic key without a username is `login:password`
|
|
1218
|
+
* to encode, or taken as already encoded when it holds no colon.
|
|
1219
|
+
*/
|
|
1220
|
+
export function authorizationFor(capability, settings) {
|
|
1221
|
+
return authorizationOf(capability.auth, settings);
|
|
1222
|
+
}
|
|
1223
|
+
/** {@link authorizationFor} for an `auth` block alone (the SDK's AI fallback holds the block, not the entry). */
|
|
1224
|
+
export function authorizationOf(auth, settings) {
|
|
1225
|
+
const key = settingValue(settings[auth.secret]);
|
|
1226
|
+
if (!key)
|
|
1227
|
+
return null;
|
|
1228
|
+
switch (auth.kind) {
|
|
1229
|
+
case "bearer":
|
|
1230
|
+
return {
|
|
1231
|
+
headers: { authorization: `${auth.scheme ?? "Bearer"} ${key}` },
|
|
1232
|
+
query: {},
|
|
1233
|
+
};
|
|
1234
|
+
case "header":
|
|
1235
|
+
return {
|
|
1236
|
+
headers: { [auth.header.toLowerCase()]: `${auth.prefix ?? ""}${key}` },
|
|
1237
|
+
query: {},
|
|
1238
|
+
};
|
|
1239
|
+
case "query":
|
|
1240
|
+
return { headers: {}, query: { [auth.param]: key } };
|
|
1241
|
+
case "basic": {
|
|
1242
|
+
const basic = (credentials) => ({
|
|
1243
|
+
headers: { authorization: `Basic ${credentials}` },
|
|
1244
|
+
query: {},
|
|
1245
|
+
});
|
|
1246
|
+
if (auth.as === "pair")
|
|
1247
|
+
return basic(basicCredentials(key));
|
|
1248
|
+
if (auth.as === "username")
|
|
1249
|
+
return basic(base64Utf8(`${key}:`));
|
|
1250
|
+
const user = typeof auth.user === "string"
|
|
1251
|
+
? auth.user
|
|
1252
|
+
: settingValue(settings[auth.user.secret]);
|
|
1253
|
+
if (!user)
|
|
1254
|
+
return null;
|
|
1255
|
+
return basic(base64Utf8(`${user}:${key}`));
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
function settingValue(value) {
|
|
1260
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* The host a provider's `hostSetting` names, from its value: a bare host
|
|
1264
|
+
* or an https origin with nothing else (no path, port, query, userinfo),
|
|
1265
|
+
* lowercased; null when unset or not of that shape. The host rules a row
|
|
1266
|
+
* must pass apply to it when the registry reads the row.
|
|
1267
|
+
*/
|
|
1268
|
+
export function hostSettingValue(value) {
|
|
1269
|
+
const text = settingValue(value);
|
|
1270
|
+
if (!text)
|
|
1271
|
+
return null;
|
|
1272
|
+
let url;
|
|
1273
|
+
try {
|
|
1274
|
+
url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(text) ? text : `https://${text}`);
|
|
1275
|
+
}
|
|
1276
|
+
catch {
|
|
1277
|
+
return null;
|
|
1278
|
+
}
|
|
1279
|
+
const bare = url.protocol === "https:" &&
|
|
1280
|
+
url.pathname === "/" &&
|
|
1281
|
+
!url.search &&
|
|
1282
|
+
!url.hash &&
|
|
1283
|
+
!url.username &&
|
|
1284
|
+
!url.password &&
|
|
1285
|
+
!url.port &&
|
|
1286
|
+
!text.endsWith("/");
|
|
1287
|
+
return bare ? url.hostname.toLowerCase() : null;
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* The hosts an API-key provider's key may be sent to: the entry's own,
|
|
1291
|
+
* and the one its `hostSetting` names when that is set and well-formed.
|
|
1292
|
+
*/
|
|
1293
|
+
export function apiKeyHosts(capability, settings) {
|
|
1294
|
+
const named = capability.hostSetting
|
|
1295
|
+
? hostSettingValue(settings?.[capability.hostSetting])
|
|
1296
|
+
: null;
|
|
1297
|
+
return [...capability.hosts, ...(named ? [named] : [])];
|
|
1298
|
+
}
|
|
1299
|
+
/** Where AppGateway serves capability calls. */
|
|
1300
|
+
export const INTEGRATION_PROXY_PREFIX = "/api/integrations";
|
|
1301
|
+
/**
|
|
1302
|
+
* The AppGateway path for a call to `upstream`:
|
|
1303
|
+
* `/api/integrations/<id>/<host><path>?<query>`. The host is in the path so
|
|
1304
|
+
* a provider with several API hosts needs no other channel.
|
|
1305
|
+
*/
|
|
1306
|
+
export function integrationProxyPath(id, upstream) {
|
|
1307
|
+
return `${INTEGRATION_PROXY_PREFIX}/${id}/${upstream.host}${upstream.pathname}${upstream.search}`;
|
|
1308
|
+
}
|
|
1309
|
+
/** The AppGateway path of a service capability's route: `/api/integrations/<id>/<route>`. */
|
|
1310
|
+
export function serviceRoutePath(id, route) {
|
|
1311
|
+
return `${INTEGRATION_PROXY_PREFIX}/${id}/${route}`;
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* The capability call an AppGateway path names, or null when the path names
|
|
1315
|
+
* an unknown capability (or an AI provider, which has its own route), a
|
|
1316
|
+
* host outside the provider's, a path that could leave that host, a path
|
|
1317
|
+
* that isn't one of an OAuth client's token endpoints, or a route a service
|
|
1318
|
+
* capability doesn't declare. `settings` resolves an OAuth client's
|
|
1319
|
+
* `hostSetting` (Mastodon's instance); `lookup` is where the id is found,
|
|
1320
|
+
* the catalog alone by default.
|
|
1321
|
+
*/
|
|
1322
|
+
export function parseIntegrationProxyPath(pathname, search, settings, lookup = CATALOG) {
|
|
1323
|
+
const match = /^\/api\/integrations\/([a-z][a-z0-9-]*)\/([^/]+)(\/.*)?$/.exec(pathname);
|
|
1324
|
+
if (!match)
|
|
1325
|
+
return null;
|
|
1326
|
+
const [, id = "", host = "", path] = match;
|
|
1327
|
+
const capability = lookup.get(id);
|
|
1328
|
+
if (!capability)
|
|
1329
|
+
return null;
|
|
1330
|
+
if (capability.mode === "service") {
|
|
1331
|
+
// A route is one segment; nothing may follow it.
|
|
1332
|
+
if (path !== undefined || !Object.hasOwn(capability.routes, host)) {
|
|
1333
|
+
return null;
|
|
1334
|
+
}
|
|
1335
|
+
return {
|
|
1336
|
+
kind: "service",
|
|
1337
|
+
id: id,
|
|
1338
|
+
capability,
|
|
1339
|
+
route: host,
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
if (capability.mode === "oauth_client") {
|
|
1343
|
+
const upstream = oauthClientHosts(capability, settings).includes(host)
|
|
1344
|
+
? httpsUpstream(host, path ?? "/", search)
|
|
1345
|
+
: null;
|
|
1346
|
+
const endpoint = upstream && tokenEndpointFor(capability, upstream, settings);
|
|
1347
|
+
if (!upstream || !endpoint)
|
|
1348
|
+
return null;
|
|
1349
|
+
return { kind: "oauth_client", id, capability, upstream, endpoint };
|
|
1350
|
+
}
|
|
1351
|
+
if (capability.mode !== "api_key")
|
|
1352
|
+
return null;
|
|
1353
|
+
const upstream = apiKeyHosts(capability, settings).includes(host)
|
|
1354
|
+
? httpsUpstream(host, path ?? "/", search)
|
|
1355
|
+
: null;
|
|
1356
|
+
if (!upstream)
|
|
1357
|
+
return null;
|
|
1358
|
+
return { kind: "api_key", id, capability, upstream };
|
|
1359
|
+
}
|
|
1360
|
+
/** `https://<host><path><search>`, or null for a path that could leave `host`. */
|
|
1361
|
+
function httpsUpstream(host, path, search) {
|
|
1362
|
+
if (path.startsWith("//") || path.includes("\\"))
|
|
1363
|
+
return null;
|
|
1364
|
+
const upstream = new URL(`https://${host}${path}${search}`);
|
|
1365
|
+
// A parser quirk must never move the key to another host.
|
|
1366
|
+
if (upstream.host !== host || upstream.protocol !== "https:")
|
|
1367
|
+
return null;
|
|
1368
|
+
return upstream;
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* The origin an OAuth client's `hostSetting` names (its default when the
|
|
1372
|
+
* setting is unset), or null for a client with a fixed host or a setting
|
|
1373
|
+
* that isn't an https origin. A bare host is taken as https.
|
|
1374
|
+
*/
|
|
1375
|
+
export function oauthClientOrigin(capability, settings) {
|
|
1376
|
+
if (!capability.hostSetting)
|
|
1377
|
+
return null;
|
|
1378
|
+
const set = settings?.[capability.hostSetting];
|
|
1379
|
+
const value = typeof set === "string" && set.trim()
|
|
1380
|
+
? set.trim()
|
|
1381
|
+
: capability.defaultOrigin;
|
|
1382
|
+
if (!value)
|
|
1383
|
+
return null;
|
|
1384
|
+
let url;
|
|
1385
|
+
try {
|
|
1386
|
+
url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `https://${value}`);
|
|
1387
|
+
}
|
|
1388
|
+
catch {
|
|
1389
|
+
return null;
|
|
1390
|
+
}
|
|
1391
|
+
// An origin and nothing else: the paths are the provider's own.
|
|
1392
|
+
const bare = url.pathname === "/" &&
|
|
1393
|
+
!url.search &&
|
|
1394
|
+
!url.hash &&
|
|
1395
|
+
!url.username &&
|
|
1396
|
+
!url.password;
|
|
1397
|
+
return url.protocol === "https:" && bare ? url.origin : null;
|
|
1398
|
+
}
|
|
1399
|
+
/** The host an OAuth client's `hostSetting` names, or null. */
|
|
1400
|
+
function settingHost(capability, settings) {
|
|
1401
|
+
const origin = oauthClientOrigin(capability, settings);
|
|
1402
|
+
return origin ? new URL(origin).host : null;
|
|
1403
|
+
}
|
|
1404
|
+
/** The hosts an OAuth client's token endpoints are on. */
|
|
1405
|
+
function oauthClientHosts(capability, settings) {
|
|
1406
|
+
const fromSetting = settingHost(capability, settings);
|
|
1407
|
+
return [
|
|
1408
|
+
...new Set(capability.token.flatMap(({ host }) => {
|
|
1409
|
+
const resolved = host ?? fromSetting;
|
|
1410
|
+
return resolved ? [resolved] : [];
|
|
1411
|
+
})),
|
|
1412
|
+
];
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* The token endpoint `upstream` is, by its exact host and path (a RegExp
|
|
1416
|
+
* for a versioned one), or null. Its method is the caller's to check.
|
|
1417
|
+
*/
|
|
1418
|
+
export function tokenEndpointFor(capability, upstream, settings) {
|
|
1419
|
+
if (upstream.protocol !== "https:")
|
|
1420
|
+
return null;
|
|
1421
|
+
const fromSetting = settingHost(capability, settings);
|
|
1422
|
+
return (capability.token.find(({ host, path }) => (host ?? fromSetting) === upstream.host &&
|
|
1423
|
+
(typeof path === "string"
|
|
1424
|
+
? path === upstream.pathname
|
|
1425
|
+
: path.test(upstream.pathname))) ?? null);
|
|
1426
|
+
}
|
|
1427
|
+
/** An OAuth client's token endpoints, as `POST https://host/path` lines for messages. */
|
|
1428
|
+
export function describeTokenEndpoints(capability) {
|
|
1429
|
+
return capability.token
|
|
1430
|
+
.map(({ host, path, methods }) => `${methods.join("/")} https://${host ?? `<${capability.hostSetting}>`}${tokenPathText(path)}`)
|
|
1431
|
+
.join(", ");
|
|
1432
|
+
}
|
|
1433
|
+
/** A token endpoint's path as people read it: a versioned one as `/v<version>/oauth/access_token`. */
|
|
1434
|
+
export function tokenPathText(path) {
|
|
1435
|
+
if (typeof path === "string")
|
|
1436
|
+
return path;
|
|
1437
|
+
return path.source
|
|
1438
|
+
.replace(/^\^|\$$/g, "")
|
|
1439
|
+
.replace(/v\\d\+\\\.\\d\+/g, "v<version>")
|
|
1440
|
+
.replace(/\\(.)/g, "$1");
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* The command that gives the gateway `capability`'s client:
|
|
1444
|
+
* `pnpm everyapp secret set <ID> <SECRET> [<HOST SETTING>]`.
|
|
1445
|
+
*/
|
|
1446
|
+
export function oauthClientSecretSetCommand(capability) {
|
|
1447
|
+
return `pnpm everyapp secret set ${capabilitySettings(capability).join(" ")}`;
|
|
1448
|
+
}
|
|
1449
|
+
/** Times a path may decode to something new before it counts as hiding one. */
|
|
1450
|
+
const MAX_PATH_DECODES = 3;
|
|
1451
|
+
/** Control, zero-width and whitespace characters: in no endpoint's path. */
|
|
1452
|
+
const UNPRINTABLE = /[\p{Cc}\p{Cf}\p{Z}\s]/u;
|
|
1453
|
+
/**
|
|
1454
|
+
* The part of a segment a provider can route on: an endpoint's name ends at
|
|
1455
|
+
* the first character no name has, so `tasks_ready,`, `tasks_ready;x` and
|
|
1456
|
+
* `tasks_ready.json` all name `tasks_ready`.
|
|
1457
|
+
*/
|
|
1458
|
+
const SEGMENT_NAME = /^[a-z0-9_-]*/;
|
|
1459
|
+
/**
|
|
1460
|
+
* Whether `pathname` reaches one of the capability's account-wide paths,
|
|
1461
|
+
* however it is spelled: percent-encoded (twice, even), in another case,
|
|
1462
|
+
* with a suffix on a segment, with doubled or backslash separators, or with
|
|
1463
|
+
* a trailing segment. A path that doesn't decode, or that holds control
|
|
1464
|
+
* characters or whitespace once decoded, counts as one; so does anything
|
|
1465
|
+
* outside ASCII for a provider that has account-wide paths, since how it
|
|
1466
|
+
* folds case is its own business.
|
|
1467
|
+
*/
|
|
1468
|
+
export function isAccountWidePath(capability, pathname) {
|
|
1469
|
+
const decoded = decodePath(pathname);
|
|
1470
|
+
if (decoded === null || UNPRINTABLE.test(decoded))
|
|
1471
|
+
return true;
|
|
1472
|
+
const blocked = capability.accountWidePaths;
|
|
1473
|
+
if (blocked.length === 0)
|
|
1474
|
+
return false;
|
|
1475
|
+
if (!/^[\x20-\x7e]*$/.test(decoded))
|
|
1476
|
+
return true;
|
|
1477
|
+
// Empty segments drop out, as on a server that merges slashes.
|
|
1478
|
+
const segments = decoded.split(/[/\\]/).filter(Boolean);
|
|
1479
|
+
// The URL parser already resolved every dot segment it could see; one
|
|
1480
|
+
// that only decoding reveals is a disguise.
|
|
1481
|
+
if (segments.some((segment) => segment === "." || segment === "..")) {
|
|
1482
|
+
return true;
|
|
1483
|
+
}
|
|
1484
|
+
const names = segments.map((segment) => SEGMENT_NAME.exec(segment)?.[0] ?? "");
|
|
1485
|
+
return blocked.some((path) => names.some((_, start) => path.every((name, offset) => names[start + offset] === name)));
|
|
1486
|
+
}
|
|
1487
|
+
/** `pathname` percent-decoded until it stops changing, lowercased; null if it won't. */
|
|
1488
|
+
function decodePath(pathname) {
|
|
1489
|
+
let current = pathname;
|
|
1490
|
+
for (let round = 0; round <= MAX_PATH_DECODES; round++) {
|
|
1491
|
+
let next;
|
|
1492
|
+
try {
|
|
1493
|
+
next = decodeURIComponent(current);
|
|
1494
|
+
}
|
|
1495
|
+
catch {
|
|
1496
|
+
return null;
|
|
1497
|
+
}
|
|
1498
|
+
if (next === current)
|
|
1499
|
+
return current.toLowerCase();
|
|
1500
|
+
current = next;
|
|
1501
|
+
}
|
|
1502
|
+
return null;
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* A Basic credential held as one setting: `login:password` as a dashboard
|
|
1506
|
+
* shows them, or the pair already base64-encoded (DataForSEO's rule, and
|
|
1507
|
+
* what OpenSEO's DATAFORSEO_API_KEY has always held). A base64 string never
|
|
1508
|
+
* holds a colon, so the colon decides.
|
|
1509
|
+
*/
|
|
1510
|
+
function basicCredentials(key) {
|
|
1511
|
+
const trimmed = key.trim();
|
|
1512
|
+
return trimmed.includes(":") ? base64Utf8(trimmed) : trimmed;
|
|
1513
|
+
}
|
|
1514
|
+
/** The base64 of `text`'s UTF-8 bytes (`btoa` takes Latin-1 only). */
|
|
1515
|
+
export function base64Utf8(text) {
|
|
1516
|
+
let binary = "";
|
|
1517
|
+
for (const byte of new TextEncoder().encode(text)) {
|
|
1518
|
+
binary += String.fromCharCode(byte);
|
|
1519
|
+
}
|
|
1520
|
+
return btoa(binary);
|
|
1521
|
+
}
|