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

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 (189) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +609 -3
  3. package/dist/cloudflare/catalogSettings.generated.d.ts +81 -0
  4. package/dist/cloudflare/catalogSettings.generated.d.ts.map +1 -0
  5. package/dist/cloudflare/catalogSettings.generated.js +174 -0
  6. package/dist/cloudflare/index.d.ts +23 -0
  7. package/dist/cloudflare/index.d.ts.map +1 -0
  8. package/dist/cloudflare/index.js +24 -0
  9. package/dist/cloudflare/sentinel.d.ts +127 -0
  10. package/dist/cloudflare/sentinel.d.ts.map +1 -0
  11. package/dist/cloudflare/sentinel.js +85 -0
  12. package/dist/cloudflare/worker.d.ts +805 -0
  13. package/dist/cloudflare/worker.d.ts.map +1 -0
  14. package/dist/cloudflare/worker.js +997 -0
  15. package/dist/dev/index.d.ts +8 -0
  16. package/dist/dev/index.d.ts.map +1 -0
  17. package/dist/dev/index.js +7 -0
  18. package/dist/dev/keypair.d.ts +26 -0
  19. package/dist/dev/keypair.d.ts.map +1 -0
  20. package/dist/dev/keypair.js +62 -0
  21. package/dist/index.d.ts +6 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +5 -0
  24. package/dist/internal/aiDefault.d.ts +204 -0
  25. package/dist/internal/aiDefault.d.ts.map +1 -0
  26. package/dist/internal/aiDefault.js +334 -0
  27. package/dist/internal/aiProviders.d.ts +72 -0
  28. package/dist/internal/aiProviders.d.ts.map +1 -0
  29. package/dist/internal/aiProviders.js +233 -0
  30. package/dist/internal/appSettings.d.ts +315 -0
  31. package/dist/internal/appSettings.d.ts.map +1 -0
  32. package/dist/internal/appSettings.js +549 -0
  33. package/dist/internal/audit.d.ts +108 -0
  34. package/dist/internal/audit.d.ts.map +1 -0
  35. package/dist/internal/audit.js +235 -0
  36. package/dist/internal/capabilities.d.ts +3174 -0
  37. package/dist/internal/capabilities.d.ts.map +1 -0
  38. package/dist/internal/capabilities.js +1110 -0
  39. package/dist/internal/catalog/commerce.d.ts +394 -0
  40. package/dist/internal/catalog/commerce.d.ts.map +1 -0
  41. package/dist/internal/catalog/commerce.js +477 -0
  42. package/dist/internal/catalog/comms.d.ts +375 -0
  43. package/dist/internal/catalog/comms.d.ts.map +1 -0
  44. package/dist/internal/catalog/comms.js +520 -0
  45. package/dist/internal/catalog/index.d.ts +1973 -0
  46. package/dist/internal/catalog/index.d.ts.map +1 -0
  47. package/dist/internal/catalog/index.js +19 -0
  48. package/dist/internal/catalog/inference.d.ts +393 -0
  49. package/dist/internal/catalog/inference.d.ts.map +1 -0
  50. package/dist/internal/catalog/inference.js +460 -0
  51. package/dist/internal/catalog/infra.d.ts +423 -0
  52. package/dist/internal/catalog/infra.d.ts.map +1 -0
  53. package/dist/internal/catalog/infra.js +543 -0
  54. package/dist/internal/catalog/search.d.ts +400 -0
  55. package/dist/internal/catalog/search.d.ts.map +1 -0
  56. package/dist/internal/catalog/search.js +463 -0
  57. package/dist/internal/devProviders.d.ts +49 -0
  58. package/dist/internal/devProviders.d.ts.map +1 -0
  59. package/dist/internal/devProviders.js +80 -0
  60. package/dist/internal/email.d.ts +280 -0
  61. package/dist/internal/email.d.ts.map +1 -0
  62. package/dist/internal/email.js +613 -0
  63. package/dist/internal/events.d.ts +193 -0
  64. package/dist/internal/events.d.ts.map +1 -0
  65. package/dist/internal/events.js +367 -0
  66. package/dist/internal/gatewaySettings.d.ts +83 -0
  67. package/dist/internal/gatewaySettings.d.ts.map +1 -0
  68. package/dist/internal/gatewaySettings.js +215 -0
  69. package/dist/internal/identity.d.ts +57 -5
  70. package/dist/internal/identity.d.ts.map +1 -1
  71. package/dist/internal/identity.js +99 -22
  72. package/dist/internal/index.d.ts +22 -1
  73. package/dist/internal/index.d.ts.map +1 -1
  74. package/dist/internal/index.js +22 -1
  75. package/dist/internal/members.d.ts +36 -0
  76. package/dist/internal/members.d.ts.map +1 -0
  77. package/dist/internal/members.js +7 -0
  78. package/dist/internal/oauthClient.d.ts +61 -0
  79. package/dist/internal/oauthClient.d.ts.map +1 -0
  80. package/dist/internal/oauthClient.js +133 -0
  81. package/dist/internal/oauthPresets.d.ts +33 -0
  82. package/dist/internal/oauthPresets.d.ts.map +1 -0
  83. package/dist/internal/oauthPresets.js +1064 -0
  84. package/dist/internal/oauthScopes.d.ts +172 -0
  85. package/dist/internal/oauthScopes.d.ts.map +1 -0
  86. package/dist/internal/oauthScopes.js +510 -0
  87. package/dist/internal/oauthSpec.d.ts +102 -0
  88. package/dist/internal/oauthSpec.d.ts.map +1 -0
  89. package/dist/internal/oauthSpec.js +789 -0
  90. package/dist/internal/organizationName.d.ts +18 -0
  91. package/dist/internal/organizationName.d.ts.map +1 -0
  92. package/dist/internal/organizationName.js +24 -0
  93. package/dist/internal/protocol.d.ts +150 -3
  94. package/dist/internal/protocol.d.ts.map +1 -1
  95. package/dist/internal/protocol.js +163 -2
  96. package/dist/internal/providerApps.d.ts +75 -0
  97. package/dist/internal/providerApps.d.ts.map +1 -0
  98. package/dist/internal/providerApps.js +59 -0
  99. package/dist/internal/providerRegistry.d.ts +218 -0
  100. package/dist/internal/providerRegistry.d.ts.map +1 -0
  101. package/dist/internal/providerRegistry.js +432 -0
  102. package/dist/internal/providerSpec.d.ts +66 -0
  103. package/dist/internal/providerSpec.d.ts.map +1 -0
  104. package/dist/internal/providerSpec.js +1154 -0
  105. package/dist/internal/remoteMcp.d.ts +188 -0
  106. package/dist/internal/remoteMcp.d.ts.map +1 -0
  107. package/dist/internal/remoteMcp.js +543 -0
  108. package/dist/internal/signInProviders.d.ts +104 -0
  109. package/dist/internal/signInProviders.d.ts.map +1 -0
  110. package/dist/internal/signInProviders.js +245 -0
  111. package/dist/internal/tools.d.ts +115 -0
  112. package/dist/internal/tools.d.ts.map +1 -0
  113. package/dist/internal/tools.js +80 -0
  114. package/dist/manifest.d.ts +238 -0
  115. package/dist/manifest.d.ts.map +1 -0
  116. package/dist/manifest.js +30 -0
  117. package/dist/server/ai.d.ts +111 -0
  118. package/dist/server/ai.d.ts.map +1 -0
  119. package/dist/server/ai.js +145 -0
  120. package/dist/server/aiBinding.d.ts +68 -0
  121. package/dist/server/aiBinding.d.ts.map +1 -0
  122. package/dist/server/aiBinding.js +294 -0
  123. package/dist/server/attribution.d.ts +43 -0
  124. package/dist/server/attribution.d.ts.map +1 -0
  125. package/dist/server/attribution.js +75 -0
  126. package/dist/server/authorizeUrl.d.ts +43 -0
  127. package/dist/server/authorizeUrl.d.ts.map +1 -0
  128. package/dist/server/authorizeUrl.js +106 -0
  129. package/dist/server/connectUrl.d.ts +21 -0
  130. package/dist/server/connectUrl.d.ts.map +1 -0
  131. package/dist/server/connectUrl.js +37 -0
  132. package/dist/server/context.d.ts +68 -0
  133. package/dist/server/context.d.ts.map +1 -0
  134. package/dist/server/context.js +117 -0
  135. package/dist/server/emailBinding.d.ts +72 -0
  136. package/dist/server/emailBinding.d.ts.map +1 -0
  137. package/dist/server/emailBinding.js +124 -0
  138. package/dist/server/emit.d.ts +63 -0
  139. package/dist/server/emit.d.ts.map +1 -0
  140. package/dist/server/emit.js +101 -0
  141. package/dist/server/env.d.ts +44 -0
  142. package/dist/server/env.d.ts.map +1 -0
  143. package/dist/server/env.js +1 -0
  144. package/dist/server/everyApp.d.ts +65 -19
  145. package/dist/server/everyApp.d.ts.map +1 -1
  146. package/dist/server/everyApp.js +195 -30
  147. package/dist/server/fromMcpTools.d.ts +89 -0
  148. package/dist/server/fromMcpTools.d.ts.map +1 -0
  149. package/dist/server/fromMcpTools.js +192 -0
  150. package/dist/server/gatewayFetch.d.ts +54 -14
  151. package/dist/server/gatewayFetch.d.ts.map +1 -1
  152. package/dist/server/gatewayFetch.js +201 -37
  153. package/dist/server/gatewayUrl.d.ts +13 -0
  154. package/dist/server/gatewayUrl.d.ts.map +1 -0
  155. package/dist/server/gatewayUrl.js +34 -0
  156. package/dist/server/index.d.ts +23 -3
  157. package/dist/server/index.d.ts.map +1 -1
  158. package/dist/server/index.js +19 -2
  159. package/dist/server/integrationFetch.d.ts +162 -0
  160. package/dist/server/integrationFetch.d.ts.map +1 -0
  161. package/dist/server/integrationFetch.js +688 -0
  162. package/dist/server/listMembers.d.ts +32 -0
  163. package/dist/server/listMembers.d.ts.map +1 -0
  164. package/dist/server/listMembers.js +71 -0
  165. package/dist/server/nodeServer.d.ts +48 -0
  166. package/dist/server/nodeServer.d.ts.map +1 -0
  167. package/dist/server/nodeServer.js +45 -0
  168. package/dist/server/refusal.d.ts +55 -0
  169. package/dist/server/refusal.d.ts.map +1 -0
  170. package/dist/server/refusal.js +193 -0
  171. package/dist/server/sendEmail.d.ts +39 -0
  172. package/dist/server/sendEmail.d.ts.map +1 -0
  173. package/dist/server/sendEmail.js +114 -0
  174. package/dist/server/tools.d.ts +184 -0
  175. package/dist/server/tools.d.ts.map +1 -0
  176. package/dist/server/tools.js +347 -0
  177. package/dist/server/toolsEndpoint.d.ts +32 -0
  178. package/dist/server/toolsEndpoint.d.ts.map +1 -0
  179. package/dist/server/toolsEndpoint.js +237 -0
  180. package/dist/tanstack-start/index.d.ts +9 -0
  181. package/dist/tanstack-start/index.d.ts.map +1 -0
  182. package/dist/tanstack-start/index.js +29 -0
  183. package/dist/testing/index.d.ts +93 -0
  184. package/dist/testing/index.d.ts.map +1 -0
  185. package/dist/testing/index.js +156 -0
  186. package/package.json +42 -8
  187. package/dist/server/mcp.d.ts +0 -66
  188. package/dist/server/mcp.d.ts.map +0 -1
  189. package/dist/server/mcp.js +0 -147
@@ -0,0 +1,1110 @@
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 (Google's, Microsoft's,
20
+ * Slack's), held the same way. The gateway completes the token endpoint
21
+ * calls only, putting the client on them; the app builds the authorize
22
+ * URL from the public client id (`integrationStatus()`), sends people
23
+ * back to a callback its manifest entry lists (`redirectPaths`), keeps
24
+ * the user tokens and calls the provider's API with them itself. Other
25
+ * providers' clients (GitHub, the social networks) are presets
26
+ * (./oauthPresets.ts): rows only once an operator adds one. A client shared by apps
27
+ * with different needs carries a `scopePolicy`: named scope sets an app
28
+ * lists in its manifest entry's `scopes`, which ./oauthScopes.ts turns
29
+ * into what the app may ask the provider for and what a token it is
30
+ * handed may carry.
31
+ * - `service`: something the gateway does itself, with no upstream host
32
+ * an app names (sending email, listing the organization's members). An
33
+ * app lists it the same way and calls its routes,
34
+ * `/api/integrations/<id>/<route>`, through the SDK's own helper
35
+ * (`sendEmail()`, `listMembers()`).
36
+ *
37
+ * The gateway enforces these definitions, the manifest validator checks
38
+ * `capabilities` against them, and the SDK reads them for its local-dev
39
+ * fallback, which calls the provider directly with a developer's own key.
40
+ * All three import them from here so they cannot drift.
41
+ */
42
+ /** What `access: "write"` adds to a provider's read methods when its entry doesn't say. */
43
+ export const DEFAULT_WRITE_METHODS = [
44
+ "POST",
45
+ "PUT",
46
+ "PATCH",
47
+ "DELETE",
48
+ ];
49
+ import { CATALOG_ENTRIES } from "./catalog/index.js";
50
+ export const CAPABILITIES = {
51
+ // The AI providers, in the order the AI proxy's table had them.
52
+ openai: {
53
+ mode: "ai",
54
+ label: "OpenAI",
55
+ description: "OpenAI's models: responses, chat, embeddings, audio, images.",
56
+ auth: { kind: "bearer", secret: "OPENAI_API_KEY" },
57
+ docs: "https://platform.openai.com/api-keys",
58
+ api: "https://platform.openai.com/docs/api-reference",
59
+ setup: "setup/openai.md",
60
+ origin: "https://api.openai.com",
61
+ inferenceRoutes: [
62
+ "/v1/responses",
63
+ "/v1/chat/completions",
64
+ "/v1/embeddings",
65
+ "/v1/audio/speech",
66
+ "/v1/audio/transcriptions",
67
+ "/v1/audio/translations",
68
+ "/v1/images/generations",
69
+ "/v1/images/edits",
70
+ "/v1/images/variations",
71
+ ],
72
+ unstoredRoutes: ["/v1/responses", "/v1/chat/completions"],
73
+ // More than inference needs both the operator (AI_PROXY_EXTRA_ROUTES)
74
+ // and the app's manifest (`ai.routes`): OpenAI paths only.
75
+ extraRoutes: true,
76
+ // Tool calling on chat completions needs `reasoning_effort: "none"`
77
+ // on the GPT-6 family (the responses route takes tools as it is).
78
+ chat: { path: "/v1/chat/completions", seedModel: "gpt-6-luna" },
79
+ embeddings: { path: "/v1/embeddings", seedModel: "text-embedding-3-small" },
80
+ // AI Gateway's OpenAI endpoint takes the path without `/v1`.
81
+ aig: { slug: "openai", trim: "/v1" },
82
+ hint: "A secret key from the API keys page; the Gateway puts it on every call as a bearer token.",
83
+ verified: { on: "2026-10-25", how: "live" },
84
+ },
85
+ // OpenAI-compatible, in front of many model providers. Usage (with cost)
86
+ // comes back in the response, so apps can meter their own spend.
87
+ openrouter: {
88
+ mode: "ai",
89
+ label: "OpenRouter",
90
+ description: "One key for many models, through OpenAI's API shape.",
91
+ auth: { kind: "bearer", secret: "OPENROUTER_API_KEY" },
92
+ docs: "https://openrouter.ai/settings/keys",
93
+ api: "https://openrouter.ai/docs",
94
+ setup: "setup/openrouter.md",
95
+ origin: "https://openrouter.ai",
96
+ inferenceRoutes: [
97
+ "/api/v1/chat/completions",
98
+ "/api/v1/completions",
99
+ "/api/v1/responses",
100
+ "/api/v1/embeddings",
101
+ ],
102
+ chat: {
103
+ path: "/api/v1/chat/completions",
104
+ seedModel: "anthropic/claude-haiku-5.5",
105
+ },
106
+ embeddings: {
107
+ path: "/api/v1/embeddings",
108
+ seedModel: "openai/text-embedding-3-small",
109
+ },
110
+ // AI Gateway's OpenRouter endpoint takes the path without `/api`.
111
+ aig: { slug: "openrouter", trim: "/api" },
112
+ hint: "An API key from OpenRouter's keys page; the Gateway puts it on every call as a bearer token.",
113
+ // Every answer's `usage.cost` is the call's price in USD, what
114
+ // OpenRouter charges the key: read off the tail with the tokens.
115
+ cost: {
116
+ unit: "token",
117
+ responseField: "usage.cost",
118
+ note: "The answer's usage.cost is the call's price in USD, per model.",
119
+ },
120
+ verified: { on: "2026-10-25", how: "live" },
121
+ },
122
+ // Workers AI's OpenAI-compatible REST API, in the gateway's own account
123
+ // (`/v1/run/...` and the native `/run/<model>` stay closed). Its chat
124
+ // streams send all-digit tokens as JSON numbers, which the gateway turns
125
+ // back into strings (normalizeWorkersAiResponse).
126
+ "workers-ai": {
127
+ mode: "ai",
128
+ label: "Workers AI",
129
+ description: "Cloudflare's models, in the Gateway's own account.",
130
+ auth: { kind: "bearer", secret: "WORKERS_AI_API_TOKEN" },
131
+ // A token from the "Workers AI" template (Read and Edit).
132
+ docs: "https://dash.cloudflare.com/profile/api-tokens",
133
+ api: "https://developers.cloudflare.com/workers-ai/",
134
+ setup: "setup/workers-ai.md",
135
+ origin: "https://api.cloudflare.com",
136
+ alsoNeeds: ["CLOUDFLARE_ACCOUNT_ID"],
137
+ basePath: "/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai",
138
+ inferenceRoutes: [
139
+ "/v1/chat/completions",
140
+ "/v1/embeddings",
141
+ "/v1/responses",
142
+ ],
143
+ normalize: "workers-ai",
144
+ chat: {
145
+ path: "/v1/chat/completions",
146
+ seedModel: "@cf/meta/llama-4-scout-17b-16e-instruct",
147
+ },
148
+ embeddings: { path: "/v1/embeddings", seedModel: "@cf/baai/bge-m3" },
149
+ // Workers AI's REST API is AI Gateway's own host: the URL stays and
150
+ // `cf-aig-gateway-id` names the gateway.
151
+ aig: { slug: "workers-ai", header: true },
152
+ hint: "A Cloudflare API token with Workers AI Read (the Workers AI page's prefilled token); the account is the Gateway's own.",
153
+ verified: { on: "2026-10-25", how: "live" },
154
+ },
155
+ // Cloudflare AI Gateway: a proxy in front of the other AI providers
156
+ // (logs, caching, rate limits, spend), with no models of its own. With
157
+ // AI_GATEWAY_ID set, every call to an `ai` entry that has an `aig` slug
158
+ // goes through it, the provider's own key still on the call; the
159
+ // gateway's own token is optional (an authenticated gateway requires
160
+ // it). Never listed under `providers`: it fronts the entries apps list.
161
+ "ai-gateway": {
162
+ mode: "ai",
163
+ kind: "proxy",
164
+ label: "Cloudflare AI Gateway",
165
+ description: "Cloudflare's proxy in front of the other AI providers: logs, caching, rate limits and spend, with each provider's own key.",
166
+ auth: {
167
+ kind: "header",
168
+ header: "cf-aig-authorization",
169
+ prefix: "Bearer ",
170
+ secret: "AI_GATEWAY_API_KEY",
171
+ },
172
+ authOptional: true,
173
+ docs: "https://dash.cloudflare.com/?to=/:account/ai/ai-gateway",
174
+ api: "https://developers.cloudflare.com/ai-gateway/",
175
+ setup: "setup/ai-gateway.md",
176
+ origin: "https://gateway.ai.cloudflare.com",
177
+ alsoNeeds: ["CLOUDFLARE_ACCOUNT_ID", "AI_GATEWAY_ID"],
178
+ basePath: "/v1/{CLOUDFLARE_ACCOUNT_ID}/{AI_GATEWAY_ID}",
179
+ inferenceRoutes: [],
180
+ hint: "The gateway's id from the AI Gateway page (everyapp ai gateway on makes one named everyapp); AI_GATEWAY_API_KEY only for an authenticated gateway, sent as cf-aig-authorization.",
181
+ secrets: [
182
+ {
183
+ name: "AI_GATEWAY_ID",
184
+ hint: "The AI Gateway's id (its name in the dashboard), in the Gateway's own Cloudflare account.",
185
+ },
186
+ {
187
+ name: "AI_GATEWAY_API_KEY",
188
+ hint: "An AI Gateway token with Run permission, only when the gateway is authenticated; leave unset for an open gateway.",
189
+ },
190
+ ],
191
+ verified: { on: "2026-10-25", how: "docs" },
192
+ },
193
+ dataforseo: {
194
+ mode: "api_key",
195
+ label: "DataForSEO",
196
+ description: "SEO data: keyword metrics, live search results, domain rankings, backlinks.",
197
+ // The key is `login:password` as the dashboard shows them, or that pair
198
+ // already base64-encoded (what OpenSEO's DATAFORSEO_API_KEY has always held).
199
+ auth: { kind: "basic", secret: "DATAFORSEO_API_KEY", as: "pair" },
200
+ docs: "https://app.dataforseo.com/api-access",
201
+ api: "https://docs.dataforseo.com/v3/",
202
+ setup: "setup/dataforseo.md",
203
+ // The sandbox answers like production, with made-up data and no charge.
204
+ hosts: ["api.dataforseo.com", "sandbox.dataforseo.com"],
205
+ access: ["read"],
206
+ // Every DataForSEO endpoint reads data, the paid live POSTs included;
207
+ // none changes anything the organization owns.
208
+ readMethods: ["GET", "POST"],
209
+ // Results are fetched by task id, which only the app that posted the
210
+ // task holds; these list every task (ready, or fixed within the last
211
+ // week), error and setting on the account.
212
+ accountWidePaths: [
213
+ ["tasks_ready"],
214
+ ["tasks_fixed"],
215
+ ["id_list"],
216
+ ["errors"],
217
+ ["appendix", "user_data"],
218
+ ["appendix", "webhook_resend"],
219
+ ],
220
+ hint: "The API login and password from API Access, as login:password (or that pair base64-encoded); the Gateway sends them as HTTP Basic.",
221
+ secrets: [
222
+ {
223
+ name: "DATAFORSEO_API_KEY",
224
+ hint: "login:password, or its base64; never the dashboard sign-in password.",
225
+ },
226
+ ],
227
+ // DataForSEO's envelope starts with the call's cost in USD.
228
+ cost: {
229
+ unit: "call",
230
+ responseField: "cost",
231
+ note: "A Google SERP $0.002 per 10 results; a Labs task $0.012 plus $0.00012 per item.",
232
+ },
233
+ smoke: {
234
+ method: "GET",
235
+ path: "/v3/appendix/user_data",
236
+ expect: [200],
237
+ free: true,
238
+ note: "Account-wide, so the Gateway refuses it for apps; run it from the person's terminal.",
239
+ },
240
+ example: "find keyword ideas for a page about running shoes",
241
+ verified: { on: "2026-10-25", how: "live" },
242
+ },
243
+ context: {
244
+ mode: "api_key",
245
+ label: "Context.dev",
246
+ description: "Scrapes and renders web pages, JavaScript included.",
247
+ auth: { kind: "bearer", secret: "CONTEXT_API_KEY" },
248
+ docs: "https://context.dev/",
249
+ setup: "setup/context.md",
250
+ hosts: ["api.context.dev"],
251
+ access: ["read"],
252
+ // Scraping and rendering pages reads the web; nothing it owns changes.
253
+ readMethods: ["GET", "POST"],
254
+ accountWidePaths: [],
255
+ hint: "An API key from the Context.dev dashboard; the Gateway puts it on every call as a bearer token.",
256
+ verified: { on: "2026-10-25", how: "live" },
257
+ },
258
+ // Outbound mail through the gateway's own sender and transport
259
+ // (apps/every-app-gateway/src/server/capabilities/email.ts): one sending
260
+ // domain onboarded per gateway, its daily budgets, attribution and logs.
261
+ email: {
262
+ mode: "service",
263
+ label: "Email",
264
+ access: ["write"],
265
+ routes: { send: ["POST"] },
266
+ // The sender is EMAIL_FROM with a display name: the gateway refuses to
267
+ // send without one. EMAIL_FROM_NAME is the plain way to give it, so the
268
+ // setup dialog and the CLI name both; EMAIL_FROM="Name <address>" stays
269
+ // accepted (parseEmailSender in internal/email.ts), and the gateway,
270
+ // which reads values, answers by its own rule (isEmailConfigured: a
271
+ // sender that parses and a provider that is selectable) rather than by
272
+ // these names. The CLI reads names alone, so it calls EMAIL_FROM_NAME
273
+ // unset until it is.
274
+ settings: ["EMAIL_FROM", "EMAIL_FROM_NAME"],
275
+ // The providers' settings (internal/email.ts chooseEmailProvider):
276
+ // Cloudflare's REST pair, sent instead of the gateway's EMAIL binding
277
+ // (a gateway outside Workers, local dev); Resend's key; and the choice
278
+ // between them when both are set.
279
+ optionalSettings: [
280
+ "EMAIL_REST_API_TOKEN",
281
+ "CLOUDFLARE_ACCOUNT_ID",
282
+ "RESEND_API_KEY",
283
+ "EMAIL_PROVIDER",
284
+ ],
285
+ setup: "setup/email.md",
286
+ },
287
+ // The organization's members who can open the app
288
+ // (apps/every-app-gateway/src/server/capabilities/members.ts): who an app
289
+ // may email or notify now, after people leave or lose access. It needs no
290
+ // setting; the gateway reads its own directory.
291
+ members: {
292
+ mode: "service",
293
+ label: "Members",
294
+ access: ["read"],
295
+ routes: { list: ["GET"] },
296
+ settings: [],
297
+ optionalSettings: [],
298
+ },
299
+ slack: {
300
+ mode: "oauth_client",
301
+ label: "Slack",
302
+ secrets: {
303
+ clientId: "SLACK_CLIENT_ID",
304
+ clientSecret: "SLACK_CLIENT_SECRET",
305
+ },
306
+ token: [
307
+ {
308
+ host: "slack.com",
309
+ path: "/api/oauth.v2.access",
310
+ methods: ["POST"],
311
+ inject: "form",
312
+ },
313
+ ],
314
+ access: ["write"],
315
+ console: "https://api.slack.com/apps",
316
+ setup: "setup/slack.md",
317
+ authorize: {
318
+ url: "https://slack.com/oauth/v2/authorize",
319
+ // The sets' union: the bot scopes in set order, then the one user
320
+ // scope no bot set carries. Postiz lists `channels.read`,
321
+ // `users.read`, `messages.write`, `channels.join` and `files.write`
322
+ // (it posts to channels it joins and uploads images); Open CRM adds
323
+ // `users.email`, `channels.manage` and `user-channels.manage` (its
324
+ // team agents match members to Slack users by email, create and
325
+ // manage the channels they post to, open DMs, and the installing
326
+ // person invites the bot to private channels).
327
+ scopes: [
328
+ "channels:read",
329
+ "groups:read",
330
+ "users:read",
331
+ "users:read.email",
332
+ "chat:write",
333
+ "chat:write.public",
334
+ "chat:write.customize",
335
+ "channels:join",
336
+ "channels:manage",
337
+ "groups:write",
338
+ "channels:write.invites",
339
+ "groups:write.invites",
340
+ "im:write",
341
+ "files:write",
342
+ "channels:write",
343
+ ],
344
+ },
345
+ scopePolicy: {
346
+ dialect: "slack",
347
+ // `oauth.v2.access` takes `code` with no `grant_type`; an app that
348
+ // opts into token rotation refreshes with `refresh_token`.
349
+ grantTypes: [null, "refresh_token"],
350
+ // Slack reads no `scope` on the token leg (the installation's scopes
351
+ // were fixed on the authorize URL), so the request goes as sent.
352
+ requestScope: "keep",
353
+ alwaysRequested: [],
354
+ tolerated: [],
355
+ deniedSegments: [],
356
+ // Slack authorizes by method name, not verb, and every method is one
357
+ // exact `/api/<method>` path on the host that also serves the token
358
+ // endpoint (which no set lists). A read set names its methods in
359
+ // `paths` (GET) and `posts` (POST) alike; a write set in `posts`
360
+ // only, with the one listing among them in `paths` too.
361
+ sets: {
362
+ "channels.read": {
363
+ label: "Channels (read-only)",
364
+ access: "read",
365
+ scopes: ["channels:read", "groups:read"],
366
+ routes: [
367
+ {
368
+ host: "slack.com",
369
+ paths: [
370
+ "/api/conversations.list",
371
+ "/api/conversations.info",
372
+ "/api/conversations.members",
373
+ "/api/users.conversations",
374
+ ],
375
+ posts: [
376
+ /^\/api\/conversations\.(list|info|members)$/,
377
+ /^\/api\/users\.conversations$/,
378
+ ],
379
+ },
380
+ ],
381
+ },
382
+ "users.read": {
383
+ label: "Members (read-only)",
384
+ access: "read",
385
+ scopes: ["users:read"],
386
+ routes: [
387
+ {
388
+ host: "slack.com",
389
+ paths: [
390
+ "/api/users.list",
391
+ "/api/users.info",
392
+ "/api/users.getPresence",
393
+ "/api/bots.info",
394
+ ],
395
+ posts: [
396
+ /^\/api\/users\.(list|info|getPresence)$/,
397
+ /^\/api\/bots\.info$/,
398
+ ],
399
+ },
400
+ ],
401
+ },
402
+ "users.email": {
403
+ // Slack grants it only beside `users:read`: declare both sets.
404
+ label: "Members' email addresses (read-only)",
405
+ access: "read",
406
+ scopes: ["users:read.email"],
407
+ routes: [
408
+ {
409
+ host: "slack.com",
410
+ paths: ["/api/users.lookupByEmail"],
411
+ posts: [/^\/api\/users\.lookupByEmail$/],
412
+ },
413
+ ],
414
+ },
415
+ "messages.write": {
416
+ label: "Messages (post as the app)",
417
+ access: "write",
418
+ scopes: ["chat:write", "chat:write.public", "chat:write.customize"],
419
+ routes: [
420
+ {
421
+ host: "slack.com",
422
+ // The scheduled-messages listing is the one read among them.
423
+ paths: ["/api/chat.scheduledMessages.list"],
424
+ posts: [
425
+ /^\/api\/chat\.(postMessage|postEphemeral|update|delete|scheduleMessage|deleteScheduledMessage|scheduledMessages\.list)$/,
426
+ ],
427
+ },
428
+ ],
429
+ },
430
+ "channels.join": {
431
+ label: "Channels (join public ones)",
432
+ access: "write",
433
+ scopes: ["channels:join"],
434
+ routes: [
435
+ {
436
+ host: "slack.com",
437
+ paths: [],
438
+ posts: [/^\/api\/conversations\.join$/],
439
+ },
440
+ ],
441
+ },
442
+ "channels.manage": {
443
+ label: "Channels (create, manage and invite to; open DMs)",
444
+ access: "write",
445
+ scopes: [
446
+ "channels:manage",
447
+ "groups:write",
448
+ "channels:write.invites",
449
+ "groups:write.invites",
450
+ "im:write",
451
+ ],
452
+ routes: [
453
+ {
454
+ host: "slack.com",
455
+ paths: [],
456
+ posts: [
457
+ /^\/api\/conversations\.(create|rename|archive|unarchive|invite|open|kick|leave|setTopic|setPurpose|mark)$/,
458
+ ],
459
+ },
460
+ ],
461
+ },
462
+ "files.write": {
463
+ label: "Files (upload)",
464
+ access: "write",
465
+ scopes: ["files:write"],
466
+ routes: [
467
+ {
468
+ host: "slack.com",
469
+ paths: [],
470
+ // The external upload flow; the retired `files.upload` stays off.
471
+ posts: [
472
+ /^\/api\/files\.(getUploadURLExternal|completeUploadExternal)$/,
473
+ ],
474
+ },
475
+ ],
476
+ },
477
+ "user-channels.manage": {
478
+ label: "Channels (as the installing person: read, manage, invite to)",
479
+ access: "write",
480
+ scopes: [],
481
+ // Slack's `user_scope`: issued to the person's own token, which
482
+ // Open CRM uses to invite the bot to private channels.
483
+ userScopes: [
484
+ "channels:read",
485
+ "channels:write",
486
+ "groups:read",
487
+ "groups:write",
488
+ ],
489
+ routes: [
490
+ {
491
+ host: "slack.com",
492
+ paths: [
493
+ "/api/conversations.list",
494
+ "/api/conversations.info",
495
+ "/api/conversations.members",
496
+ "/api/users.conversations",
497
+ ],
498
+ posts: [
499
+ /^\/api\/conversations\.(list|info|members|create|rename|archive|unarchive|invite|kick|leave|setTopic|setPurpose|mark)$/,
500
+ /^\/api\/users\.conversations$/,
501
+ ],
502
+ },
503
+ ],
504
+ },
505
+ },
506
+ },
507
+ api: ["slack.com"],
508
+ // Slack refuses `code_challenge`; `auth.test` names the workspace and
509
+ // the installing person.
510
+ flow: {
511
+ tokenUrl: "https://slack.com/api/oauth.v2.access",
512
+ revokeUrl: "https://slack.com/api/auth.revoke",
513
+ pkce: false,
514
+ scopeDialect: "slack",
515
+ identity: {
516
+ url: "https://slack.com/api/auth.test",
517
+ label: "team",
518
+ id: "user_id",
519
+ },
520
+ },
521
+ },
522
+ // Clients several apps share with different needs, so each app lists the
523
+ // scope sets it uses: Open CRM reads a member's own mailbox and calendar
524
+ // (`gmail.read`, `calendar.read`; `identity`, `mail.read`), OpenSEO reads
525
+ // a site's Search Console and Analytics (`identity`, `search-console.read`,
526
+ // `analytics.read`). Every set is read-only. Google's is one Cloud project
527
+ // apart from the YouTube preset's; Gmail's read scope is restricted and needs
528
+ // Google's verification, and whether Search Console and Analytics share
529
+ // that consent screen with it is the operator's choice when verifying.
530
+ "google-workspace": {
531
+ mode: "oauth_client",
532
+ label: "Google (Gmail, Calendar, Search Console and Analytics)",
533
+ secrets: {
534
+ clientId: "GOOGLE_WORKSPACE_CLIENT_ID",
535
+ clientSecret: "GOOGLE_WORKSPACE_CLIENT_SECRET",
536
+ },
537
+ token: [
538
+ {
539
+ host: "oauth2.googleapis.com",
540
+ path: "/token",
541
+ methods: ["POST"],
542
+ inject: "form",
543
+ },
544
+ ],
545
+ access: ["write"],
546
+ console: "https://console.cloud.google.com/apis/credentials",
547
+ setup: "setup/google.md",
548
+ authorize: {
549
+ url: "https://accounts.google.com/o/oauth2/v2/auth",
550
+ scopes: [
551
+ "openid",
552
+ "email",
553
+ "profile",
554
+ "https://www.googleapis.com/auth/gmail.readonly",
555
+ "https://www.googleapis.com/auth/calendar.readonly",
556
+ "https://www.googleapis.com/auth/webmasters.readonly",
557
+ "https://www.googleapis.com/auth/analytics.readonly",
558
+ ],
559
+ },
560
+ scopePolicy: {
561
+ dialect: "google",
562
+ // Google documents no scope on either token leg: the response's
563
+ // `scope` is where the gateway learns what a token holds.
564
+ grantTypes: ["authorization_code", "refresh_token"],
565
+ requestScope: "drop",
566
+ alwaysRequested: [],
567
+ // Both spellings: Google documents `email`/`profile` and the userinfo
568
+ // URLs as the same grants, and echoes the URLs.
569
+ tolerated: [
570
+ "openid",
571
+ "email",
572
+ "profile",
573
+ "https://www.googleapis.com/auth/userinfo.email",
574
+ "https://www.googleapis.com/auth/userinfo.profile",
575
+ ],
576
+ // Gmail's and Calendar's `/batch/<api>/<version>` carry whole requests
577
+ // inside; Analytics' measurement protocol secrets are readable under
578
+ // the read-only scope.
579
+ deniedSegments: ["batch", "measurementProtocolSecrets"],
580
+ sets: {
581
+ identity: {
582
+ label: "Google account (who you are)",
583
+ access: "read",
584
+ scopes: ["openid", "email", "profile"],
585
+ routes: [
586
+ { host: "openidconnect.googleapis.com", paths: ["/v1/userinfo"] },
587
+ {
588
+ host: "www.googleapis.com",
589
+ paths: [
590
+ "/oauth2/v2/userinfo",
591
+ "/oauth2/v3/userinfo",
592
+ "/userinfo/v2/me",
593
+ ],
594
+ },
595
+ ],
596
+ },
597
+ "gmail.read": {
598
+ label: "Gmail (read-only)",
599
+ access: "read",
600
+ scopes: ["https://www.googleapis.com/auth/gmail.readonly"],
601
+ routes: [
602
+ {
603
+ host: "gmail.googleapis.com",
604
+ paths: ["/gmail/v1/users/"],
605
+ // Push notifications start and stop; `messages/send`, drafts
606
+ // and `modify` are the other POSTs under the same prefix.
607
+ posts: [/^\/gmail\/v1\/users\/[^/]+\/(watch|stop)$/],
608
+ },
609
+ ],
610
+ },
611
+ "calendar.read": {
612
+ label: "Google Calendar (read-only)",
613
+ access: "read",
614
+ scopes: ["https://www.googleapis.com/auth/calendar.readonly"],
615
+ routes: [
616
+ {
617
+ host: "www.googleapis.com",
618
+ paths: ["/calendar/v3/"],
619
+ posts: [
620
+ /^\/calendar\/v3\/freeBusy$/,
621
+ /^\/calendar\/v3\/calendars\/[^/]+\/events\/watch$/,
622
+ /^\/calendar\/v3\/users\/me\/calendarList\/watch$/,
623
+ /^\/calendar\/v3\/channels\/stop$/,
624
+ ],
625
+ },
626
+ ],
627
+ },
628
+ "search-console.read": {
629
+ label: "Search Console (read-only)",
630
+ access: "read",
631
+ scopes: ["https://www.googleapis.com/auth/webmasters.readonly"],
632
+ // The v3 paths answer on both hosts; URL inspection is v1 only.
633
+ // A site is one segment, `https%3A%2F%2Fexample.com%2F` or
634
+ // `sc-domain%3Aexample.com`, which is why routes match the raw path.
635
+ routes: [
636
+ {
637
+ host: "www.googleapis.com",
638
+ paths: ["/webmasters/v3/"],
639
+ posts: [
640
+ /^\/webmasters\/v3\/sites\/[^/]+\/searchAnalytics\/query$/,
641
+ ],
642
+ },
643
+ {
644
+ host: "searchconsole.googleapis.com",
645
+ paths: ["/webmasters/v3/"],
646
+ posts: [
647
+ /^\/webmasters\/v3\/sites\/[^/]+\/searchAnalytics\/query$/,
648
+ /^\/v1\/urlInspection\/index:inspect$/,
649
+ ],
650
+ },
651
+ ],
652
+ },
653
+ "analytics.read": {
654
+ label: "Google Analytics (read-only)",
655
+ access: "read",
656
+ scopes: ["https://www.googleapis.com/auth/analytics.readonly"],
657
+ routes: [
658
+ {
659
+ // Reports are POSTed queries.
660
+ host: "analyticsdata.googleapis.com",
661
+ paths: ["/v1beta/properties/"],
662
+ posts: [
663
+ /^\/v1beta\/properties\/[^/]+:(runReport|batchRunReports|runPivotReport|batchRunPivotReports|runRealtimeReport|checkCompatibility)$/,
664
+ ],
665
+ },
666
+ {
667
+ // Reads only: accounts, properties and what hangs off them
668
+ // (data streams, key events, enhanced measurement settings).
669
+ host: "analyticsadmin.googleapis.com",
670
+ paths: [
671
+ "/v1beta/accountSummaries",
672
+ "/v1beta/accounts",
673
+ "/v1beta/accounts/",
674
+ "/v1beta/properties",
675
+ "/v1beta/properties/",
676
+ "/v1alpha/properties/",
677
+ ],
678
+ },
679
+ ],
680
+ },
681
+ },
682
+ },
683
+ api: [
684
+ "gmail.googleapis.com",
685
+ "www.googleapis.com",
686
+ "searchconsole.googleapis.com",
687
+ "analyticsdata.googleapis.com",
688
+ "analyticsadmin.googleapis.com",
689
+ "openidconnect.googleapis.com",
690
+ ],
691
+ // The Gateway-owned flow: a refresh token needs `access_type=offline`,
692
+ // and `prompt=consent` makes Google issue one on every connect. No
693
+ // revoke: Google's `/revoke` is per person per client and would also
694
+ // kill the tokens an app-driven OpenSEO holds under the same client.
695
+ flow: {
696
+ tokenUrl: "https://oauth2.googleapis.com/token",
697
+ pkce: true,
698
+ scopeDialect: "google",
699
+ authorizeParams: { access_type: "offline", prompt: "consent" },
700
+ identity: {
701
+ url: "https://openidconnect.googleapis.com/v1/userinfo",
702
+ label: "email",
703
+ id: "sub",
704
+ },
705
+ },
706
+ },
707
+ "microsoft-365": {
708
+ mode: "oauth_client",
709
+ label: "Microsoft 365 (Outlook mail and calendar)",
710
+ secrets: {
711
+ clientId: "MICROSOFT_365_CLIENT_ID",
712
+ clientSecret: "MICROSOFT_365_CLIENT_SECRET",
713
+ },
714
+ token: [
715
+ {
716
+ // The multi-tenant endpoint: register the client for accounts in
717
+ // any organization and personal accounts. A single-tenant client
718
+ // answers only on its tenant's path, which this does not complete.
719
+ host: "login.microsoftonline.com",
720
+ path: "/common/oauth2/v2.0/token",
721
+ methods: ["POST"],
722
+ inject: "form",
723
+ },
724
+ ],
725
+ access: ["write"],
726
+ console: "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
727
+ setup: "setup/microsoft-365.md",
728
+ authorize: {
729
+ url: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
730
+ scopes: ["offline_access", "User.Read", "Mail.Read", "Calendars.Read"],
731
+ },
732
+ scopePolicy: {
733
+ dialect: "microsoft",
734
+ grantTypes: ["authorization_code", "refresh_token"],
735
+ // The token leg's `scope` is Microsoft's extension for naming the
736
+ // resource; the gateway sets it to the app's list so a token covers
737
+ // Graph alone.
738
+ requestScope: "replace",
739
+ // Refresh tokens; Entra never echoes it in a response's `scope`.
740
+ alwaysRequested: ["offline_access"],
741
+ // Compared case-insensitively, as Microsoft spells them both ways.
742
+ tolerated: ["openid", "profile", "email", "offline_access", "User.Read"],
743
+ // `$batch` carries whole requests inside; `beta` is another API.
744
+ deniedSegments: ["$batch", "beta"],
745
+ // `/v1.0/me/…` only: `/v1.0/users/<id>/…` reaches shared mailboxes
746
+ // and calendars under the same permissions.
747
+ sets: {
748
+ identity: {
749
+ label: "Microsoft account (who you are)",
750
+ access: "read",
751
+ scopes: ["User.Read"],
752
+ routes: [
753
+ {
754
+ host: "graph.microsoft.com",
755
+ paths: ["/v1.0/me", "/oidc/userinfo"],
756
+ },
757
+ ],
758
+ },
759
+ "mail.read": {
760
+ label: "Outlook mail (read-only)",
761
+ access: "read",
762
+ scopes: ["Mail.Read"],
763
+ routes: [
764
+ {
765
+ host: "graph.microsoft.com",
766
+ paths: [
767
+ "/v1.0/me/messages",
768
+ "/v1.0/me/messages/",
769
+ "/v1.0/me/mailFolders",
770
+ "/v1.0/me/mailFolders/",
771
+ ],
772
+ },
773
+ ],
774
+ },
775
+ "calendar.read": {
776
+ label: "Outlook calendar (read-only)",
777
+ access: "read",
778
+ scopes: ["Calendars.Read"],
779
+ routes: [
780
+ {
781
+ host: "graph.microsoft.com",
782
+ paths: [
783
+ "/v1.0/me/calendar",
784
+ "/v1.0/me/calendar/",
785
+ "/v1.0/me/calendars",
786
+ "/v1.0/me/calendars/",
787
+ "/v1.0/me/events",
788
+ "/v1.0/me/events/",
789
+ "/v1.0/me/calendarView",
790
+ "/v1.0/me/calendarView/",
791
+ ],
792
+ // Free/busy is a POSTed query.
793
+ posts: [/^\/v1\.0\/me\/calendar\/getSchedule$/],
794
+ },
795
+ ],
796
+ },
797
+ },
798
+ },
799
+ api: ["graph.microsoft.com"],
800
+ flow: {
801
+ tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
802
+ pkce: true,
803
+ scopeDialect: "microsoft",
804
+ identity: {
805
+ url: "https://graph.microsoft.com/v1.0/me",
806
+ label: "userPrincipalName",
807
+ id: "id",
808
+ },
809
+ },
810
+ },
811
+ // The first fifty beyond the entries above (catalog/*.ts, PR 3 of the
812
+ // provider registry), after them so the derived lists keep their order.
813
+ ...CATALOG_ENTRIES,
814
+ };
815
+ /** The Gateway settings an API-key or AI provider's `auth` names: the key first, then a Basic username held as a setting. */
816
+ export function authSettingNames(auth) {
817
+ const names = [auth.secret];
818
+ if (auth.kind === "basic" &&
819
+ auth.as === "password" &&
820
+ typeof auth.user === "object") {
821
+ names.push(auth.user.secret);
822
+ }
823
+ return names;
824
+ }
825
+ /** Every gateway setting `capability` reads, the ones it needs first. */
826
+ export function capabilitySettings(capability) {
827
+ switch (capability.mode) {
828
+ case "api_key":
829
+ return [
830
+ ...authSettingNames(capability.auth),
831
+ ...(capability.hostSetting ? [capability.hostSetting] : []),
832
+ ];
833
+ case "ai":
834
+ return [capability.auth.secret, ...(capability.alsoNeeds ?? [])];
835
+ case "service":
836
+ return [...capability.settings, ...capability.optionalSettings];
837
+ case "oauth_client":
838
+ return [
839
+ capability.secrets.clientId,
840
+ ...(capability.secrets.clientSecret
841
+ ? [capability.secrets.clientSecret]
842
+ : []),
843
+ ];
844
+ }
845
+ }
846
+ export const CAPABILITY_IDS = Object.keys(CAPABILITIES);
847
+ export function isCapabilityId(value) {
848
+ return typeof value === "string" && Object.hasOwn(CAPABILITIES, value);
849
+ }
850
+ /** The built-in catalog as a lookup: what every surface reads without a gateway's rows. */
851
+ export const CATALOG = {
852
+ get: (id) => (isCapabilityId(id) ? CAPABILITIES[id] : undefined),
853
+ ids: () => CAPABILITY_IDS,
854
+ };
855
+ /** The ids an app may list under `capabilities`: every provider but the AI ones, which go under `providers`. */
856
+ export function capabilityIdsOf(lookup) {
857
+ return lookup.ids().filter((id) => lookup.get(id)?.mode !== "ai");
858
+ }
859
+ /**
860
+ * The settings `capability` needs on a request, read from the Gateway's
861
+ * settings (or `.dev.vars`): the headers to set and the query parameters
862
+ * to add, or null while any setting its `auth` names is unset or blank.
863
+ * Values are trimmed. A Basic key without a username is `login:password`
864
+ * to encode, or taken as already encoded when it holds no colon.
865
+ */
866
+ export function authorizationFor(capability, settings) {
867
+ return authorizationOf(capability.auth, settings);
868
+ }
869
+ /** {@link authorizationFor} for an `auth` block alone (the SDK's AI fallback holds the block, not the entry). */
870
+ export function authorizationOf(auth, settings) {
871
+ const key = settingValue(settings[auth.secret]);
872
+ if (!key)
873
+ return null;
874
+ switch (auth.kind) {
875
+ case "bearer":
876
+ return {
877
+ headers: { authorization: `${auth.scheme ?? "Bearer"} ${key}` },
878
+ query: {},
879
+ };
880
+ case "header":
881
+ return {
882
+ headers: { [auth.header.toLowerCase()]: `${auth.prefix ?? ""}${key}` },
883
+ query: {},
884
+ };
885
+ case "query":
886
+ return { headers: {}, query: { [auth.param]: key } };
887
+ case "basic": {
888
+ const basic = (credentials) => ({
889
+ headers: { authorization: `Basic ${credentials}` },
890
+ query: {},
891
+ });
892
+ if (auth.as === "pair")
893
+ return basic(basicCredentials(key));
894
+ if (auth.as === "username")
895
+ return basic(base64Utf8(`${key}:`));
896
+ const user = typeof auth.user === "string"
897
+ ? auth.user
898
+ : settingValue(settings[auth.user.secret]);
899
+ if (!user)
900
+ return null;
901
+ return basic(base64Utf8(`${user}:${key}`));
902
+ }
903
+ }
904
+ }
905
+ function settingValue(value) {
906
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
907
+ }
908
+ /**
909
+ * The host a provider's `hostSetting` names, from its value: a bare host
910
+ * or an https origin with nothing else (no path, port, query, userinfo),
911
+ * lowercased; null when unset or not of that shape. The host rules a row
912
+ * must pass apply to it when the registry reads the row.
913
+ */
914
+ export function hostSettingValue(value) {
915
+ const text = settingValue(value);
916
+ if (!text)
917
+ return null;
918
+ let url;
919
+ try {
920
+ url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(text) ? text : `https://${text}`);
921
+ }
922
+ catch {
923
+ return null;
924
+ }
925
+ const bare = url.protocol === "https:" &&
926
+ url.pathname === "/" &&
927
+ !url.search &&
928
+ !url.hash &&
929
+ !url.username &&
930
+ !url.password &&
931
+ !url.port &&
932
+ !text.endsWith("/");
933
+ return bare ? url.hostname.toLowerCase() : null;
934
+ }
935
+ /**
936
+ * The hosts an API-key provider's key may be sent to: the entry's own,
937
+ * and the one its `hostSetting` names when that is set and well-formed.
938
+ */
939
+ export function apiKeyHosts(capability, settings) {
940
+ const named = capability.hostSetting
941
+ ? hostSettingValue(settings?.[capability.hostSetting])
942
+ : null;
943
+ return [...capability.hosts, ...(named ? [named] : [])];
944
+ }
945
+ /** Where AppGateway serves capability calls. */
946
+ export const INTEGRATION_PROXY_PREFIX = "/api/integrations";
947
+ /**
948
+ * The AppGateway path for a call to `upstream`:
949
+ * `/api/integrations/<id>/<host><path>?<query>`. The host is in the path so
950
+ * a provider with several API hosts needs no other channel.
951
+ */
952
+ export function integrationProxyPath(id, upstream) {
953
+ return `${INTEGRATION_PROXY_PREFIX}/${id}/${upstream.host}${upstream.pathname}${upstream.search}`;
954
+ }
955
+ /** The AppGateway path of a service capability's route: `/api/integrations/<id>/<route>`. */
956
+ export function serviceRoutePath(id, route) {
957
+ return `${INTEGRATION_PROXY_PREFIX}/${id}/${route}`;
958
+ }
959
+ /**
960
+ * The capability call an AppGateway path names, or null when the path names
961
+ * an unknown capability (or an AI provider, which has its own route), a
962
+ * host outside the provider's, a path that could leave that host, a path
963
+ * that isn't one of an OAuth client's token endpoints, or a route a service
964
+ * capability doesn't declare. `settings` resolves an API-key provider's
965
+ * `hostSetting` (a Shopify store's host); `lookup` is where the id is found,
966
+ * the catalog alone by default.
967
+ */
968
+ export function parseIntegrationProxyPath(pathname, search, settings, lookup = CATALOG) {
969
+ const match = /^\/api\/integrations\/([a-z][a-z0-9-]*)\/([^/]+)(\/.*)?$/.exec(pathname);
970
+ if (!match)
971
+ return null;
972
+ const [, id = "", host = "", path] = match;
973
+ const capability = lookup.get(id);
974
+ if (!capability)
975
+ return null;
976
+ if (capability.mode === "service") {
977
+ // A route is one segment; nothing may follow it.
978
+ if (path !== undefined || !Object.hasOwn(capability.routes, host)) {
979
+ return null;
980
+ }
981
+ return {
982
+ kind: "service",
983
+ id: id,
984
+ capability,
985
+ route: host,
986
+ };
987
+ }
988
+ if (capability.mode === "oauth_client") {
989
+ const upstream = capability.token.some((rule) => rule.host === host)
990
+ ? httpsUpstream(host, path ?? "/", search)
991
+ : null;
992
+ const endpoint = upstream && tokenEndpointFor(capability, upstream);
993
+ if (!upstream || !endpoint)
994
+ return null;
995
+ return { kind: "oauth_client", id, capability, upstream, endpoint };
996
+ }
997
+ if (capability.mode !== "api_key")
998
+ return null;
999
+ const upstream = apiKeyHosts(capability, settings).includes(host)
1000
+ ? httpsUpstream(host, path ?? "/", search)
1001
+ : null;
1002
+ if (!upstream)
1003
+ return null;
1004
+ return { kind: "api_key", id, capability, upstream };
1005
+ }
1006
+ /** `https://<host><path><search>`, or null for a path that could leave `host`. */
1007
+ function httpsUpstream(host, path, search) {
1008
+ if (path.startsWith("//") || path.includes("\\"))
1009
+ return null;
1010
+ const upstream = new URL(`https://${host}${path}${search}`);
1011
+ // A parser quirk must never move the key to another host.
1012
+ if (upstream.host !== host || upstream.protocol !== "https:")
1013
+ return null;
1014
+ return upstream;
1015
+ }
1016
+ /**
1017
+ * The token endpoint `upstream` is, by its exact host and path, or null.
1018
+ * Its method is the caller's to check.
1019
+ */
1020
+ export function tokenEndpointFor(capability, upstream) {
1021
+ if (upstream.protocol !== "https:")
1022
+ return null;
1023
+ return (capability.token.find(({ host, path }) => host === upstream.host && path === upstream.pathname) ?? null);
1024
+ }
1025
+ /** An OAuth client's token endpoints, as `POST https://host/path` lines for messages. */
1026
+ export function describeTokenEndpoints(capability) {
1027
+ return capability.token
1028
+ .map(({ host, path, methods }) => `${methods.join("/")} https://${host}${path}`)
1029
+ .join(", ");
1030
+ }
1031
+ /**
1032
+ * The command that gives the gateway `capability`'s client:
1033
+ * `pnpm everyapp secret set <ID> <SECRET>`.
1034
+ */
1035
+ export function oauthClientSecretSetCommand(capability) {
1036
+ return `pnpm everyapp secret set ${capabilitySettings(capability).join(" ")}`;
1037
+ }
1038
+ /** Times a path may decode to something new before it counts as hiding one. */
1039
+ const MAX_PATH_DECODES = 3;
1040
+ /** Control, zero-width and whitespace characters: in no endpoint's path. */
1041
+ const UNPRINTABLE = /[\p{Cc}\p{Cf}\p{Z}\s]/u;
1042
+ /**
1043
+ * The part of a segment a provider can route on: an endpoint's name ends at
1044
+ * the first character no name has, so `tasks_ready,`, `tasks_ready;x` and
1045
+ * `tasks_ready.json` all name `tasks_ready`.
1046
+ */
1047
+ const SEGMENT_NAME = /^[a-z0-9_-]*/;
1048
+ /**
1049
+ * Whether `pathname` reaches one of the capability's account-wide paths,
1050
+ * however it is spelled: percent-encoded (twice, even), in another case,
1051
+ * with a suffix on a segment, with doubled or backslash separators, or with
1052
+ * a trailing segment. A path that doesn't decode, or that holds control
1053
+ * characters or whitespace once decoded, counts as one; so does anything
1054
+ * outside ASCII for a provider that has account-wide paths, since how it
1055
+ * folds case is its own business.
1056
+ */
1057
+ export function isAccountWidePath(capability, pathname) {
1058
+ const decoded = decodePath(pathname);
1059
+ if (decoded === null || UNPRINTABLE.test(decoded))
1060
+ return true;
1061
+ const blocked = capability.accountWidePaths;
1062
+ if (blocked.length === 0)
1063
+ return false;
1064
+ if (!/^[\x20-\x7e]*$/.test(decoded))
1065
+ return true;
1066
+ // Empty segments drop out, as on a server that merges slashes.
1067
+ const segments = decoded.split(/[/\\]/).filter(Boolean);
1068
+ // The URL parser already resolved every dot segment it could see; one
1069
+ // that only decoding reveals is a disguise.
1070
+ if (segments.some((segment) => segment === "." || segment === "..")) {
1071
+ return true;
1072
+ }
1073
+ const names = segments.map((segment) => SEGMENT_NAME.exec(segment)?.[0] ?? "");
1074
+ return blocked.some((path) => names.some((_, start) => path.every((name, offset) => names[start + offset] === name)));
1075
+ }
1076
+ /** `pathname` percent-decoded until it stops changing, lowercased; null if it won't. */
1077
+ function decodePath(pathname) {
1078
+ let current = pathname;
1079
+ for (let round = 0; round <= MAX_PATH_DECODES; round++) {
1080
+ let next;
1081
+ try {
1082
+ next = decodeURIComponent(current);
1083
+ }
1084
+ catch {
1085
+ return null;
1086
+ }
1087
+ if (next === current)
1088
+ return current.toLowerCase();
1089
+ current = next;
1090
+ }
1091
+ return null;
1092
+ }
1093
+ /**
1094
+ * A Basic credential held as one setting: `login:password` as a dashboard
1095
+ * shows them, or the pair already base64-encoded (DataForSEO's rule, and
1096
+ * what OpenSEO's DATAFORSEO_API_KEY has always held). A base64 string never
1097
+ * holds a colon, so the colon decides.
1098
+ */
1099
+ function basicCredentials(key) {
1100
+ const trimmed = key.trim();
1101
+ return trimmed.includes(":") ? base64Utf8(trimmed) : trimmed;
1102
+ }
1103
+ /** The base64 of `text`'s UTF-8 bytes (`btoa` takes Latin-1 only). */
1104
+ export function base64Utf8(text) {
1105
+ let binary = "";
1106
+ for (const byte of new TextEncoder().encode(text)) {
1107
+ binary += String.fromCharCode(byte);
1108
+ }
1109
+ return btoa(binary);
1110
+ }