@every-app/sdk 0.2.0 → 0.3.0-rc.2

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