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

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 (120) hide show
  1. package/README.md +67 -67
  2. package/dist/cloudflare/catalogSettings.generated.d.ts +7 -4
  3. package/dist/cloudflare/catalogSettings.generated.d.ts.map +1 -1
  4. package/dist/cloudflare/catalogSettings.generated.js +7 -6
  5. package/dist/cloudflare/index.d.ts +1 -1
  6. package/dist/cloudflare/index.d.ts.map +1 -1
  7. package/dist/cloudflare/worker.d.ts +36 -2
  8. package/dist/cloudflare/worker.d.ts.map +1 -1
  9. package/dist/cloudflare/worker.js +32 -6
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/internal/aiDefault.d.ts +36 -79
  13. package/dist/internal/aiDefault.d.ts.map +1 -1
  14. package/dist/internal/aiDefault.js +47 -149
  15. package/dist/internal/aiProviders.d.ts.map +1 -1
  16. package/dist/internal/aiProviders.js +2 -2
  17. package/dist/internal/appSettings.d.ts +14 -17
  18. package/dist/internal/appSettings.d.ts.map +1 -1
  19. package/dist/internal/appSettings.js +2 -16
  20. package/dist/internal/capabilities.d.ts +106 -489
  21. package/dist/internal/capabilities.d.ts.map +1 -1
  22. package/dist/internal/capabilities.js +49 -550
  23. package/dist/internal/catalog/commerce.js +1 -1
  24. package/dist/internal/catalog/index.d.ts +35 -0
  25. package/dist/internal/catalog/index.d.ts.map +1 -1
  26. package/dist/internal/catalog/infra.d.ts +35 -0
  27. package/dist/internal/catalog/infra.d.ts.map +1 -1
  28. package/dist/internal/catalog/infra.js +65 -0
  29. package/dist/internal/events.d.ts +52 -1
  30. package/dist/internal/events.d.ts.map +1 -1
  31. package/dist/internal/events.js +65 -3
  32. package/dist/internal/gatewaySettings.d.ts +2 -2
  33. package/dist/internal/gatewaySettings.d.ts.map +1 -1
  34. package/dist/internal/gatewaySettings.js +0 -6
  35. package/dist/internal/oauthBuiltIns.d.ts +101 -0
  36. package/dist/internal/oauthBuiltIns.d.ts.map +1 -0
  37. package/dist/internal/oauthBuiltIns.js +122 -0
  38. package/dist/internal/oauthClient.d.ts +7 -47
  39. package/dist/internal/oauthClient.d.ts.map +1 -1
  40. package/dist/internal/oauthClient.js +1 -115
  41. package/dist/internal/oauthPresets.d.ts +14 -15
  42. package/dist/internal/oauthPresets.d.ts.map +1 -1
  43. package/dist/internal/oauthPresets.js +42 -853
  44. package/dist/internal/oauthScopes.d.ts +43 -155
  45. package/dist/internal/oauthScopes.d.ts.map +1 -1
  46. package/dist/internal/oauthScopes.js +82 -475
  47. package/dist/internal/oauthSpec.d.ts +72 -71
  48. package/dist/internal/oauthSpec.d.ts.map +1 -1
  49. package/dist/internal/oauthSpec.js +178 -447
  50. package/dist/internal/protocol.d.ts +24 -14
  51. package/dist/internal/protocol.d.ts.map +1 -1
  52. package/dist/internal/protocol.js +26 -7
  53. package/dist/internal/providerRegistry.d.ts +1 -61
  54. package/dist/internal/providerRegistry.d.ts.map +1 -1
  55. package/dist/internal/providerRegistry.js +8 -115
  56. package/dist/internal/providerSpec.d.ts.map +1 -1
  57. package/dist/internal/providerSpec.js +14 -36
  58. package/dist/internal/remoteMcp.d.ts +4 -6
  59. package/dist/internal/remoteMcp.d.ts.map +1 -1
  60. package/dist/internal/remoteMcp.js +8 -15
  61. package/dist/internal/tools.d.ts +12 -7
  62. package/dist/internal/tools.d.ts.map +1 -1
  63. package/dist/internal/tools.js +9 -8
  64. package/dist/manifest.d.ts +41 -28
  65. package/dist/manifest.d.ts.map +1 -1
  66. package/dist/manifest.js +1 -1
  67. package/dist/server/ai.d.ts +1 -93
  68. package/dist/server/ai.d.ts.map +1 -1
  69. package/dist/server/ai.js +17 -111
  70. package/dist/server/attribution.d.ts +10 -1
  71. package/dist/server/attribution.d.ts.map +1 -1
  72. package/dist/server/attribution.js +7 -1
  73. package/dist/server/connectUrl.d.ts +4 -9
  74. package/dist/server/connectUrl.d.ts.map +1 -1
  75. package/dist/server/connectUrl.js +17 -14
  76. package/dist/server/context.d.ts +12 -14
  77. package/dist/server/context.d.ts.map +1 -1
  78. package/dist/server/context.js +23 -25
  79. package/dist/server/eventsEndpoint.d.ts +48 -0
  80. package/dist/server/eventsEndpoint.d.ts.map +1 -0
  81. package/dist/server/eventsEndpoint.js +145 -0
  82. package/dist/server/everyApp.d.ts +16 -26
  83. package/dist/server/everyApp.d.ts.map +1 -1
  84. package/dist/server/everyApp.js +22 -46
  85. package/dist/server/gatewayFetch.d.ts +1 -1
  86. package/dist/server/gatewayFetch.js +1 -1
  87. package/dist/server/index.d.ts +6 -9
  88. package/dist/server/index.d.ts.map +1 -1
  89. package/dist/server/index.js +4 -8
  90. package/dist/server/integrationFetch.d.ts +60 -72
  91. package/dist/server/integrationFetch.d.ts.map +1 -1
  92. package/dist/server/integrationFetch.js +117 -392
  93. package/dist/server/refusal.d.ts +0 -9
  94. package/dist/server/refusal.d.ts.map +1 -1
  95. package/dist/server/refusal.js +0 -22
  96. package/dist/server/tools.d.ts +13 -5
  97. package/dist/server/tools.d.ts.map +1 -1
  98. package/dist/server/tools.js +10 -3
  99. package/dist/server/toolsEndpoint.d.ts +2 -1
  100. package/dist/server/toolsEndpoint.d.ts.map +1 -1
  101. package/dist/server/toolsEndpoint.js +4 -3
  102. package/dist/server/webhooks.d.ts +56 -0
  103. package/dist/server/webhooks.d.ts.map +1 -0
  104. package/dist/server/webhooks.js +205 -0
  105. package/dist/testing/index.d.ts +2 -3
  106. package/dist/testing/index.d.ts.map +1 -1
  107. package/dist/testing/index.js +2 -6
  108. package/package.json +1 -1
  109. package/dist/server/aiBinding.d.ts +0 -68
  110. package/dist/server/aiBinding.d.ts.map +0 -1
  111. package/dist/server/aiBinding.js +0 -294
  112. package/dist/server/authorizeUrl.d.ts +0 -43
  113. package/dist/server/authorizeUrl.d.ts.map +0 -1
  114. package/dist/server/authorizeUrl.js +0 -106
  115. package/dist/server/emailBinding.d.ts +0 -72
  116. package/dist/server/emailBinding.d.ts.map +0 -1
  117. package/dist/server/emailBinding.js +0 -124
  118. package/dist/server/fromMcpTools.d.ts +0 -89
  119. package/dist/server/fromMcpTools.d.ts.map +0 -1
  120. package/dist/server/fromMcpTools.js +0 -192
@@ -1,22 +1,4 @@
1
- /**
2
- * The `oauth2-user` half of a provider spec
3
- * (docs/design/2026-10-oauth-providers.md section 3): a registered OAuth
4
- * provider, or a preset (./oauthPresets.ts), as JSON, turned into the
5
- * same {@link OAuthClientCapability} a catalog client is, with a `flow`
6
- * the Gateway runs itself. `parseProviderSpec` (./providerSpec.ts) hands
7
- * the common fields here once it sees `auth.kind: "oauth2-user"`; nothing
8
- * here reads a value: a spec names Gateway settings, never holds one.
9
- */
10
- import { DEFAULT_WRITE_METHODS, } from "./capabilities.js";
11
- /** A scope set's name: `identity`, or `<area>.<access>` (`repo.read`), lowercase. */
12
- export const SCOPE_SET_NAME = /^[a-z][a-z0-9-]{0,31}(?:\.[a-z][a-z0-9-]{0,31})?$/;
13
- const DIALECTS = [
14
- "space",
15
- "comma",
16
- "google",
17
- "microsoft",
18
- "slack",
19
- ];
1
+ const SEPARATORS = ["space", "comma"];
20
2
  const INJECTIONS = ["form", "basic"];
21
3
  const HEADER_NAME = /^[A-Za-z0-9-]{1,64}$/;
22
4
  /** Headers the Gateway sets on the token request itself, or never sends. */
@@ -27,17 +9,27 @@ const RESERVED_TOKEN_HEADERS = new Set([
27
9
  "host",
28
10
  "cookie",
29
11
  ]);
12
+ /**
13
+ * Authorize parameters the Gateway sets itself, and
14
+ * `include_granted_scopes`, which would widen a token to every scope the
15
+ * person ever granted the client (the mint check would refuse it anyway).
16
+ */
17
+ const RESERVED_AUTHORIZE_PARAMS = [
18
+ "client_id",
19
+ "redirect_uri",
20
+ "response_type",
21
+ "scope",
22
+ "state",
23
+ "code_challenge",
24
+ "code_challenge_method",
25
+ "include_granted_scopes",
26
+ ];
30
27
  const PARAM_NAME = /^[A-Za-z0-9_.-]{1,64}$/;
31
28
  /** A dot path into a JSON answer, at most four segments. */
32
29
  const DOT_PATH = /^[A-Za-z_][A-Za-z0-9_]{0,63}(?:\.[A-Za-z_][A-Za-z0-9_]{0,63}){0,3}$/;
33
30
  const UNPRINTABLE = /[\p{Cc}\p{Cf}]/u;
34
- const MAX_SETS = 20;
35
31
  const MAX_SCOPES = 40;
36
- const MAX_ROUTES = 20;
37
- const MAX_PATHS = 50;
38
32
  const MAX_PARAMS = 10;
39
- /** Segments no set unlocks on any host: batch tunnels carry whole requests inside. */
40
- export const DEFAULT_DENIED_SEGMENTS = ["batch", "$batch"];
41
33
  /** The fields of `auth` when its kind is `oauth2-user`. */
42
34
  export const OAUTH_AUTH_FIELDS = [
43
35
  "kind",
@@ -49,11 +41,30 @@ export const OAUTH_AUTH_FIELDS = [
49
41
  "clientSecret",
50
42
  "inject",
51
43
  "pkce",
52
- "scopeDialect",
44
+ "scopeSeparator",
45
+ "scopePrefix",
53
46
  "authorizeParams",
54
47
  "tokenHeaders",
55
48
  "identity",
49
+ "tolerated",
56
50
  ];
51
+ /**
52
+ * Fields an `oauth2-user` spec had before scopes moved to each app's
53
+ * `everyapp.config.ts`, each with what to write instead: refused with
54
+ * that message rather than "unknown field".
55
+ */
56
+ export const REMOVED_OAUTH_FIELDS = {
57
+ scopeSets: "scope sets are gone: each app lists the provider's own scopes in its everyapp.config.ts. Remove scopeSets.",
58
+ alwaysRequested: "alwaysRequested is gone: put scopes every connect needs (a refresh token's, the identity URL's) in auth.identity.scopes.",
59
+ tolerated: "tolerated moved into auth: auth.tolerated lists the scopes a token may carry beyond what was asked.",
60
+ access: "access is gone for an OAuth provider: the scopes an app lists are the grant, for every method.",
61
+ readMethods: "readMethods is gone for an OAuth provider: the scopes an app lists are the grant, for every method.",
62
+ writeMethods: "writeMethods is gone for an OAuth provider: the scopes an app lists are the grant, for every method.",
63
+ };
64
+ /** `auth` fields renamed, each with its new name. */
65
+ const RENAMED_AUTH_FIELDS = {
66
+ scopeDialect: "renamed auth.scopeSeparator: space or comma (a provider's prefix goes in auth.scopePrefix)",
67
+ };
57
68
  function isRecord(value) {
58
69
  return typeof value === "object" && value !== null && !Array.isArray(value);
59
70
  }
@@ -109,8 +120,8 @@ function endpoint(value, path, problems, { query }) {
109
120
  }
110
121
  return { url, host: parsed.hostname.toLowerCase() };
111
122
  }
112
- function settingName(value, path, problems, pattern) {
113
- if (typeof value !== "string" || !pattern.test(value)) {
123
+ function settingName(value, path, problems) {
124
+ if (typeof value !== "string" || !/^[A-Z][A-Z0-9_]{2,63}$/.test(value)) {
114
125
  problems.push({
115
126
  path,
116
127
  message: "must name a Gateway setting: upper case letters, digits and underscores, a letter first, 3 to 64 characters (GITHUB_CLIENT_ID)",
@@ -141,7 +152,8 @@ function stringMap(value, path, problems, each) {
141
152
  }
142
153
  return out;
143
154
  }
144
- function scopeList(value, path, problems) {
155
+ /** A list of provider scope strings: each one scope, with no space or comma, each once. */
156
+ export function scopeList(value, path, problems) {
145
157
  if (value === undefined)
146
158
  return undefined;
147
159
  if (!Array.isArray(value) || value.length > MAX_SCOPES) {
@@ -170,290 +182,18 @@ function scopeList(value, path, problems) {
170
182
  }
171
183
  return out;
172
184
  }
173
- function routes(value, path, hosts, problems) {
174
- if (value === undefined)
175
- return undefined;
176
- if (!Array.isArray(value) || value.length > MAX_ROUTES) {
177
- problems.push({
178
- path,
179
- message: `must be a list of at most ${MAX_ROUTES} routes`,
180
- });
181
- return undefined;
182
- }
183
- const out = [];
184
- value.forEach((item, index) => {
185
- const at = `${path}.${index}`;
186
- if (!isRecord(item)) {
187
- problems.push({ path: at, message: "must be { host, paths, posts? }" });
188
- return;
189
- }
190
- for (const field of Object.keys(item)) {
191
- if (!["host", "paths", "posts"].includes(field)) {
192
- problems.push({ path: `${at}.${field}`, message: "unknown field" });
193
- }
194
- }
195
- const host = printable(item["host"], `${at}.host`, problems, 253);
196
- if (host !== undefined && !hosts.includes(host)) {
197
- problems.push({ path: `${at}.host`, message: "must be one of hosts" });
198
- }
199
- const paths = [];
200
- if (!Array.isArray(item["paths"]) || item["paths"].length > MAX_PATHS) {
201
- problems.push({
202
- path: `${at}.paths`,
203
- message: `must be a list of at most ${MAX_PATHS} paths`,
204
- });
205
- }
206
- else {
207
- item["paths"].forEach((entry, offset) => {
208
- const text = printable(entry, `${at}.paths.${offset}`, problems, 500);
209
- if (text === undefined)
210
- return;
211
- if (!text.startsWith("/") ||
212
- text.includes("//") ||
213
- text.includes("\\") ||
214
- text.includes("%") ||
215
- /(^|\/)\.\.?(\/|$)/.test(text)) {
216
- problems.push({
217
- path: `${at}.paths.${offset}`,
218
- message: "must start with / and hold no //, backslash, percent-escape or dot segment",
219
- });
220
- return;
221
- }
222
- paths.push(text);
223
- });
224
- }
225
- const posts = [];
226
- if (item["posts"] !== undefined) {
227
- if (!Array.isArray(item["posts"]) || item["posts"].length > MAX_PATHS) {
228
- problems.push({
229
- path: `${at}.posts`,
230
- message: `must be a list of at most ${MAX_PATHS} anchored patterns`,
231
- });
232
- }
233
- else {
234
- item["posts"].forEach((entry, offset) => {
235
- const text = printable(entry, `${at}.posts.${offset}`, problems, 500);
236
- if (text === undefined)
237
- return;
238
- if (!text.startsWith("^") || !text.endsWith("$")) {
239
- problems.push({
240
- path: `${at}.posts.${offset}`,
241
- message: "must be anchored: start with ^ and end with $",
242
- });
243
- return;
244
- }
245
- try {
246
- posts.push(new RegExp(text));
247
- }
248
- catch {
249
- problems.push({
250
- path: `${at}.posts.${offset}`,
251
- message: "is not a valid pattern",
252
- });
253
- }
254
- });
255
- }
256
- }
257
- if (host === undefined)
258
- return;
259
- out.push({
260
- host,
261
- paths,
262
- ...(item["posts"] === undefined ? {} : { posts }),
263
- });
264
- });
265
- return out;
266
- }
267
- /** The fields one scope set may carry, in a spec's `scopeSets` or an overlay. */
268
- const SCOPE_SET_FIELDS = ["label", "access", "scopes", "userScopes", "routes"];
269
- /**
270
- * One scope set (`{ label, access, scopes, userScopes?, routes? }`) at
271
- * `at`, its routes on `hosts` alone; undefined when a field is wrong,
272
- * every problem reported. `overlay`: a set added to a built-in client
273
- * must name a scope and say what it unlocks (a set with no routes unlocks
274
- * nothing on the held-token path); a spec's own set may leave both empty
275
- * (Notion's sets carry no scope string).
276
- */
277
- export function parseScopeSet(raw, at, hosts, problems, { overlay = false } = {}) {
278
- if (!isRecord(raw)) {
279
- problems.push({ path: at, message: "must be { label, access, scopes }" });
280
- return undefined;
281
- }
282
- for (const field of Object.keys(raw)) {
283
- if (!SCOPE_SET_FIELDS.includes(field)) {
284
- problems.push({ path: `${at}.${field}`, message: "unknown field" });
285
- }
286
- }
287
- const label = printable(raw["label"], `${at}.label`, problems, 80);
288
- if (raw["label"] === undefined) {
289
- problems.push({ path: `${at}.label`, message: "required" });
290
- }
291
- const access = raw["access"];
292
- if (access !== "read" && access !== "write") {
293
- problems.push({ path: `${at}.access`, message: "must be read or write" });
294
- }
295
- const scopes = scopeList(raw["scopes"], `${at}.scopes`, problems);
296
- if (raw["scopes"] === undefined) {
297
- problems.push({ path: `${at}.scopes`, message: "required" });
298
- }
299
- else if (overlay && scopes && scopes.length === 0) {
300
- problems.push({ path: `${at}.scopes`, message: "must list a scope" });
301
- }
302
- const userScopes = scopeList(raw["userScopes"], `${at}.userScopes`, problems);
303
- const parsedRoutes = routes(raw["routes"], `${at}.routes`, hosts, problems);
304
- if (overlay && (!parsedRoutes || parsedRoutes.length === 0)) {
305
- problems.push({
306
- path: `${at}.routes`,
307
- message: "required: at least one { host, paths } the set unlocks, on one of the client's API hosts",
308
- });
309
- }
310
- if (label === undefined ||
311
- (access !== "read" && access !== "write") ||
312
- scopes === undefined) {
313
- return undefined;
314
- }
315
- return {
316
- label,
317
- access,
318
- scopes,
319
- ...(userScopes === undefined ? {} : { userScopes }),
320
- routes: parsedRoutes ?? [],
321
- };
322
- }
323
- function scopeSets(value, hosts, problems) {
324
- if (!isRecord(value)) {
325
- problems.push({
326
- path: "scopeSets",
327
- message: 'required: at least one named set, { "repo.read": { "label", "access", "scopes" } }',
328
- });
329
- return undefined;
330
- }
331
- const entries = Object.entries(value);
332
- if (entries.length === 0 || entries.length > MAX_SETS) {
333
- problems.push({
334
- path: "scopeSets",
335
- message: `must name 1 to ${MAX_SETS} sets`,
336
- });
337
- return undefined;
338
- }
339
- const out = {};
340
- for (const [name, raw] of entries) {
341
- const at = `scopeSets.${name}`;
342
- if (!SCOPE_SET_NAME.test(name)) {
343
- problems.push({
344
- path: at,
345
- message: "a set is named identity, or <area>.<access> in lowercase (repo.read)",
346
- });
347
- continue;
348
- }
349
- const set = parseScopeSet(raw, at, hosts, problems);
350
- if (set)
351
- out[name] = set;
352
- }
353
- return out;
354
- }
355
- /** The fields a scope-set overlay carries: a set's, with its `name` and the client it belongs to. */
356
- const OVERLAY_FIELDS = ["provider", "name", ...SCOPE_SET_FIELDS];
357
- /**
358
- * A scope set an operator adds to a built-in OAuth client
359
- * (`everyapp provider scope-set add google-workspace gmail-send.json`):
360
- * `{ "name": "gmail.send", "label", "access", "scopes", "routes" }`, held
361
- * to the rules a built-in set is (the set's shape, routes on the client's
362
- * `api` hosts alone, anchored `posts`) plus two of its own: `access` is
363
- * said outright (a write set is never implied by its scopes) and `routes`
364
- * is required. A `provider` field, when present, must be `id`. The name
365
- * may not be one of the client's own sets: an overlay adds, never
366
- * replaces. Nothing here reads a value.
367
- */
368
- export function parseScopeSetOverlay(input, client) {
369
- const problems = [];
370
- if (!isRecord(input)) {
371
- return {
372
- ok: false,
373
- problems: [{ path: "(root)", message: "must be a JSON object" }],
374
- };
375
- }
376
- for (const field of Object.keys(input)) {
377
- if (!OVERLAY_FIELDS.includes(field)) {
378
- problems.push({ path: field, message: "unknown field" });
379
- }
380
- }
381
- const policy = client.capability.scopePolicy;
382
- if (!policy) {
383
- problems.push({
384
- path: "(root)",
385
- message: `${client.id} has no scope sets: the whole client is the grant`,
386
- });
387
- }
388
- if (input["provider"] !== undefined && input["provider"] !== client.id) {
389
- problems.push({
390
- path: "provider",
391
- message: `is ${String(input["provider"])}, not ${client.id}`,
392
- });
393
- }
394
- const name = input["name"];
395
- if (typeof name !== "string" || !SCOPE_SET_NAME.test(name)) {
396
- problems.push({
397
- path: "name",
398
- message: "required: identity, or <area>.<access> in lowercase (gmail.send)",
399
- });
400
- }
401
- else if (policy && Object.hasOwn(policy.sets, name)) {
402
- problems.push({
403
- path: "name",
404
- message: `${name} is one of ${client.id}'s own sets; an overlay adds a set, never replaces one`,
405
- });
406
- }
407
- // The set's own fields alone: an unknown one was reported above.
408
- const fields = Object.fromEntries(Object.entries(input).filter(([field]) => SCOPE_SET_FIELDS.includes(field)));
409
- const set = parseScopeSet(fields, "(set)", client.capability.api, problems, {
410
- overlay: true,
411
- });
412
- if (problems.length > 0 || !set || typeof name !== "string") {
413
- return { ok: false, problems };
414
- }
415
- return { ok: true, name, set };
416
- }
417
- /**
418
- * `capability` with `sets` added to its scope policy: the sets after the
419
- * client's own, in the order given, and each set's scopes added to the
420
- * authorize URL's list (what the operator's client must be allowed), as
421
- * `parseOAuthUserSpec` builds it. A name the client already has is left
422
- * as the client's (the registry refuses such an overlay before this).
423
- */
424
- export function withScopeSets(capability, sets) {
425
- const policy = capability.scopePolicy;
426
- if (!policy)
427
- return capability;
428
- const merged = { ...policy.sets };
429
- const scopes = [...capability.authorize.scopes];
430
- for (const [name, set] of Object.entries(sets)) {
431
- if (Object.hasOwn(merged, name))
432
- continue;
433
- merged[name] = set;
434
- for (const scope of set.scopes) {
435
- if (!scopes.includes(scope))
436
- scopes.push(scope);
437
- }
438
- }
439
- return {
440
- ...capability,
441
- authorize: { ...capability.authorize, scopes },
442
- scopePolicy: { ...policy, sets: merged },
443
- };
444
- }
445
185
  function identity(value, hosts, problems) {
446
186
  if (value === undefined)
447
187
  return undefined;
448
188
  if (!isRecord(value)) {
449
189
  problems.push({
450
190
  path: "auth.identity",
451
- message: "must be { url, label, id? }",
191
+ message: "must be { url, label, id?, scopes? }",
452
192
  });
453
193
  return undefined;
454
194
  }
455
195
  for (const field of Object.keys(value)) {
456
- if (!["url", "label", "id"].includes(field)) {
196
+ if (!["url", "label", "id", "scopes"].includes(field)) {
457
197
  problems.push({
458
198
  path: `auth.identity.${field}`,
459
199
  message: "unknown field",
@@ -489,30 +229,34 @@ function identity(value, hosts, problems) {
489
229
  message: "must be a JSON field name, or a dotted path to a nested one",
490
230
  });
491
231
  }
232
+ const scopes = scopeList(value["scopes"], "auth.identity.scopes", problems);
492
233
  if (!url || label === undefined)
493
234
  return undefined;
494
- return { url: url.url, label, ...(id === undefined ? {} : { id }) };
235
+ return {
236
+ identity: { url: url.url, label, ...(id === undefined ? {} : { id }) },
237
+ scopes: scopes ?? [],
238
+ };
495
239
  }
496
240
  /**
497
- * Parse the OAuth half of a spec whose `auth.kind` is `oauth2-user`:
498
- * `auth` itself, `scopeSets`, `alwaysRequested` and `tolerated`, with
499
- * the common fields already parsed. Every problem is reported; on
500
- * success the catalog-shaped capability with its `flow`.
241
+ * Parse the OAuth half of a spec whose `auth.kind` is `oauth2-user`, with
242
+ * the common fields already parsed. Every problem is reported; on success
243
+ * the capability with its `flow`.
501
244
  */
502
245
  export function parseOAuthUserSpec(input, common, problems) {
503
- const settingName_ = (value, path, into) => settingName(value, path, into, /^[A-Z][A-Z0-9_]{2,63}$/);
504
246
  const auth = input["auth"];
505
247
  for (const field of Object.keys(auth)) {
506
- if (!OAUTH_AUTH_FIELDS.includes(field)) {
248
+ const renamed = RENAMED_AUTH_FIELDS[field];
249
+ if (renamed) {
250
+ problems.push({ path: `auth.${field}`, message: renamed });
251
+ }
252
+ else if (!OAUTH_AUTH_FIELDS.includes(field)) {
507
253
  problems.push({
508
254
  path: `auth.${field}`,
509
255
  message: "not a field of auth.kind oauth2-user",
510
256
  });
511
257
  }
512
258
  }
513
- const authorize = endpoint(auth["authorizeUrl"], "auth.authorizeUrl", problems, {
514
- query: true,
515
- });
259
+ const authorize = endpoint(auth["authorizeUrl"], "auth.authorizeUrl", problems, { query: true });
516
260
  if (auth["authorizeUrl"] === undefined) {
517
261
  problems.push({ path: "auth.authorizeUrl", message: "required" });
518
262
  }
@@ -535,15 +279,12 @@ export function parseOAuthUserSpec(input, common, problems) {
535
279
  });
536
280
  }
537
281
  if (revokeMethod !== undefined && revoke === undefined) {
538
- problems.push({
539
- path: "auth.revokeMethod",
540
- message: "needs a revokeUrl",
541
- });
282
+ problems.push({ path: "auth.revokeMethod", message: "needs a revokeUrl" });
542
283
  }
543
- const clientId = settingName_(auth["clientId"], "auth.clientId", problems);
284
+ const clientId = settingName(auth["clientId"], "auth.clientId", problems);
544
285
  const clientSecret = auth["clientSecret"] === undefined
545
286
  ? undefined
546
- : settingName_(auth["clientSecret"], "auth.clientSecret", problems);
287
+ : settingName(auth["clientSecret"], "auth.clientSecret", problems);
547
288
  if (clientSecret !== undefined && clientSecret === clientId) {
548
289
  problems.push({
549
290
  path: "auth.clientSecret",
@@ -561,29 +302,32 @@ export function parseOAuthUserSpec(input, common, problems) {
561
302
  if (typeof pkce !== "boolean") {
562
303
  problems.push({ path: "auth.pkce", message: "must be true or false" });
563
304
  }
564
- const dialect = auth["scopeDialect"] ?? "space";
565
- if (!DIALECTS.includes(dialect)) {
305
+ const separator = auth["scopeSeparator"] ?? "space";
306
+ if (!SEPARATORS.includes(separator)) {
566
307
  problems.push({
567
- path: "auth.scopeDialect",
568
- message: `must be one of ${DIALECTS.join(", ")}`,
308
+ path: "auth.scopeSeparator",
309
+ message: `must be one of ${SEPARATORS.join(", ")}`,
310
+ });
311
+ }
312
+ const prefix = endpoint(auth["scopePrefix"], "auth.scopePrefix", problems, {
313
+ query: false,
314
+ });
315
+ if (prefix && !prefix.url.endsWith("/")) {
316
+ problems.push({
317
+ path: "auth.scopePrefix",
318
+ message: "must end in / (https://www.googleapis.com/auth/): an app's scope is joined onto it",
569
319
  });
570
320
  }
571
321
  const authorizeParams = stringMap(auth["authorizeParams"], "auth.authorizeParams", problems, (name, at) => {
572
322
  if (!PARAM_NAME.test(name)) {
573
323
  problems.push({ path: at, message: "must be a query parameter name" });
574
324
  }
575
- if ([
576
- "client_id",
577
- "redirect_uri",
578
- "response_type",
579
- "scope",
580
- "state",
581
- "code_challenge",
582
- "code_challenge_method",
583
- ].includes(name)) {
325
+ if (RESERVED_AUTHORIZE_PARAMS.includes(name)) {
584
326
  problems.push({
585
327
  path: at,
586
- message: `${name} is a parameter the Gateway sets itself`,
328
+ message: name === "include_granted_scopes"
329
+ ? "include_granted_scopes would widen the token beyond the app's scopes; the Gateway never sends it"
330
+ : `${name} is a parameter the Gateway sets itself`,
587
331
  });
588
332
  }
589
333
  });
@@ -600,78 +344,53 @@ export function parseOAuthUserSpec(input, common, problems) {
600
344
  }
601
345
  });
602
346
  const parsedIdentity = identity(auth["identity"], common.hosts, problems);
603
- const sets = scopeSets(input["scopeSets"], common.hosts, problems);
604
- const alwaysRequested = scopeList(input["alwaysRequested"], "alwaysRequested", problems);
605
- const tolerated = scopeList(input["tolerated"], "tolerated", problems);
347
+ const tolerated = scopeList(auth["tolerated"], "auth.tolerated", problems);
606
348
  if (!authorize ||
607
349
  !token ||
608
350
  clientId === undefined ||
609
- !sets ||
610
351
  typeof pkce !== "boolean" ||
611
- !DIALECTS.includes(dialect) ||
352
+ !SEPARATORS.includes(separator) ||
612
353
  !INJECTIONS.includes(inject)) {
613
354
  return undefined;
614
355
  }
615
- const union = [];
616
- const add = (scopes) => {
617
- for (const scope of scopes)
618
- if (!union.includes(scope))
619
- union.push(scope);
620
- };
621
- for (const set of Object.values(sets)) {
622
- add(set.scopes);
623
- add(set.userScopes ?? []);
624
- }
625
- add(alwaysRequested ?? []);
626
- const policy = {
627
- sets,
628
- tolerated: tolerated ?? [],
629
- alwaysRequested: alwaysRequested ?? [],
630
- dialect: dialect,
631
- grantTypes: ["authorization_code", "refresh_token"],
632
- requestScope: "keep",
633
- deniedSegments: [...DEFAULT_DENIED_SEGMENTS],
634
- };
635
356
  const flow = {
357
+ authorizeUrl: authorize.url,
636
358
  tokenUrl: token.url,
637
359
  ...(revoke === undefined ? {} : { revokeUrl: revoke.url }),
638
360
  ...(revokeMethod === undefined
639
361
  ? {}
640
362
  : { revokeMethod: revokeMethod }),
363
+ inject: inject,
641
364
  pkce,
642
- scopeDialect: dialect,
365
+ scopeSeparator: separator,
366
+ ...(prefix === undefined ? {} : { scopePrefix: prefix.url }),
643
367
  ...(authorizeParams === undefined ? {} : { authorizeParams }),
644
368
  ...(tokenHeaders === undefined ? {} : { tokenHeaders }),
645
- ...(parsedIdentity === undefined ? {} : { identity: parsedIdentity }),
369
+ ...(parsedIdentity === undefined
370
+ ? {}
371
+ : { identity: parsedIdentity.identity }),
372
+ identityScopes: parsedIdentity?.scopes ?? [],
373
+ tolerated: tolerated ?? [],
646
374
  };
647
- const tokenPath = new URL(token.url).pathname;
648
- const capability = {
375
+ const capability = oauthCapabilityOf(common, {
376
+ clientId,
377
+ ...(clientSecret === undefined ? {} : { clientSecret }),
378
+ }, flow);
379
+ const hosts = [...common.hosts];
380
+ for (const host of [authorize.host, token.host, revoke?.host]) {
381
+ if (host && !hosts.includes(host))
382
+ hosts.push(host);
383
+ }
384
+ return { capability, hosts };
385
+ }
386
+ function oauthCapabilityOf(common, secrets, flow) {
387
+ return {
649
388
  mode: "oauth_client",
650
389
  label: common.name,
651
- secrets: {
652
- clientId,
653
- ...(clientSecret === undefined ? {} : { clientSecret }),
654
- },
655
- token: [
656
- {
657
- host: token.host,
658
- path: tokenPath,
659
- methods: ["POST"],
660
- inject: inject,
661
- },
662
- ],
663
- access: common.access ?? ["read"],
390
+ secrets,
664
391
  console: common.docs,
665
- authorize: { url: authorize.url, scopes: union },
666
- scopePolicy: policy,
667
392
  api: [...common.hosts],
668
393
  flow,
669
- ...(common.readMethods === undefined
670
- ? {}
671
- : { readMethods: common.readMethods }),
672
- ...(common.writeMethods === undefined
673
- ? {}
674
- : { writeMethods: common.writeMethods }),
675
394
  ...(common.description === undefined
676
395
  ? {}
677
396
  : { description: common.description }),
@@ -680,24 +399,78 @@ export function parseOAuthUserSpec(input, common, problems) {
680
399
  ...(common.example === undefined ? {} : { example: common.example }),
681
400
  ...(common.tags === undefined ? {} : { tags: common.tags }),
682
401
  };
683
- const hosts = [...common.hosts];
684
- for (const host of [authorize.host, token.host, revoke?.host]) {
685
- if (host && !hosts.includes(host))
686
- hosts.push(host);
687
- }
688
- return { capability, hosts };
689
402
  }
690
403
  /**
691
- * The JSON spec of an OAuth capability that has a `flow` (`provider
692
- * show`, a preset's round trip): `parseProviderSpec` of it gives the
693
- * capability back. A catalog client without a `flow` has no spec.
404
+ * A spec already known to be good (a built-in, checked by its test) as
405
+ * its capability, without the parse: what the catalog runs at load, where
406
+ * calling the full parser would be an import cycle.
407
+ */
408
+ export function oauthCapabilityFromSpec(spec) {
409
+ const { auth } = spec;
410
+ const flow = {
411
+ authorizeUrl: auth.authorizeUrl,
412
+ tokenUrl: auth.tokenUrl,
413
+ ...(auth.revokeUrl === undefined ? {} : { revokeUrl: auth.revokeUrl }),
414
+ ...(auth.revokeMethod === undefined
415
+ ? {}
416
+ : { revokeMethod: auth.revokeMethod }),
417
+ inject: auth.inject ?? "form",
418
+ pkce: auth.pkce ?? true,
419
+ scopeSeparator: auth.scopeSeparator ?? "space",
420
+ ...(auth.scopePrefix === undefined
421
+ ? {}
422
+ : { scopePrefix: auth.scopePrefix }),
423
+ ...(auth.authorizeParams === undefined
424
+ ? {}
425
+ : { authorizeParams: { ...auth.authorizeParams } }),
426
+ ...(auth.tokenHeaders === undefined
427
+ ? {}
428
+ : { tokenHeaders: { ...auth.tokenHeaders } }),
429
+ ...(auth.identity === undefined
430
+ ? {}
431
+ : {
432
+ identity: {
433
+ url: auth.identity.url,
434
+ label: auth.identity.label,
435
+ ...(auth.identity.id === undefined ? {} : { id: auth.identity.id }),
436
+ },
437
+ }),
438
+ identityScopes: [...(auth.identity?.scopes ?? [])],
439
+ tolerated: [...(auth.tolerated ?? [])],
440
+ };
441
+ return oauthCapabilityOf({
442
+ id: spec.id,
443
+ name: spec.name,
444
+ docs: spec.docs,
445
+ hosts: spec.hosts,
446
+ ...(spec.description === undefined
447
+ ? {}
448
+ : { description: spec.description }),
449
+ ...(spec.api === undefined ? {} : { reference: spec.api }),
450
+ ...(spec.hint === undefined ? {} : { hint: spec.hint }),
451
+ ...(spec.example === undefined ? {} : { example: spec.example }),
452
+ ...(spec.tags === undefined ? {} : { tags: spec.tags }),
453
+ }, {
454
+ clientId: auth.clientId,
455
+ ...(auth.clientSecret === undefined
456
+ ? {}
457
+ : { clientSecret: auth.clientSecret }),
458
+ }, flow);
459
+ }
460
+ /**
461
+ * The JSON spec of an OAuth capability (`provider show`, a preset's round
462
+ * trip): `parseProviderSpec` of it gives the capability back.
694
463
  */
695
464
  export function oauthSpecOf(id, capability) {
696
- const flow = capability.flow;
697
- const policy = capability.scopePolicy;
698
- if (!flow || !policy)
699
- return null;
700
- const inject = capability.token[0]?.inject ?? "form";
465
+ const { flow } = capability;
466
+ const identity = flow.identity === undefined
467
+ ? undefined
468
+ : {
469
+ ...flow.identity,
470
+ ...(flow.identityScopes.length === 0
471
+ ? {}
472
+ : { scopes: [...flow.identityScopes] }),
473
+ };
701
474
  return {
702
475
  id,
703
476
  name: capability.label,
@@ -711,7 +484,7 @@ export function oauthSpecOf(id, capability) {
711
484
  hosts: [...capability.api],
712
485
  auth: {
713
486
  kind: "oauth2-user",
714
- authorizeUrl: capability.authorize.url,
487
+ authorizeUrl: flow.authorizeUrl,
715
488
  tokenUrl: flow.tokenUrl,
716
489
  ...(flow.revokeUrl === undefined ? {} : { revokeUrl: flow.revokeUrl }),
717
490
  ...(flow.revokeMethod === undefined
@@ -721,57 +494,23 @@ export function oauthSpecOf(id, capability) {
721
494
  ...(capability.secrets.clientSecret === undefined
722
495
  ? {}
723
496
  : { clientSecret: capability.secrets.clientSecret }),
724
- inject,
497
+ inject: flow.inject,
725
498
  pkce: flow.pkce,
726
- scopeDialect: flow.scopeDialect,
499
+ scopeSeparator: flow.scopeSeparator,
500
+ ...(flow.scopePrefix === undefined
501
+ ? {}
502
+ : { scopePrefix: flow.scopePrefix }),
727
503
  ...(flow.authorizeParams === undefined
728
504
  ? {}
729
505
  : { authorizeParams: { ...flow.authorizeParams } }),
730
506
  ...(flow.tokenHeaders === undefined
731
507
  ? {}
732
508
  : { tokenHeaders: { ...flow.tokenHeaders } }),
733
- ...(flow.identity === undefined
509
+ ...(identity === undefined ? {} : { identity }),
510
+ ...(flow.tolerated.length === 0
734
511
  ? {}
735
- : { identity: { ...flow.identity } }),
512
+ : { tolerated: [...flow.tolerated] }),
736
513
  },
737
- scopeSets: Object.fromEntries(Object.entries(policy.sets).map(([name, set]) => [
738
- name,
739
- {
740
- label: set.label,
741
- access: set.access,
742
- scopes: [...set.scopes],
743
- ...(set.userScopes === undefined
744
- ? {}
745
- : { userScopes: [...set.userScopes] }),
746
- ...(set.routes.length === 0
747
- ? {}
748
- : {
749
- routes: set.routes.map((route) => ({
750
- host: route.host,
751
- paths: [...route.paths],
752
- // `source` escapes `/` as `\/`; the spec wrote it bare.
753
- ...(route.posts === undefined
754
- ? {}
755
- : {
756
- posts: route.posts.map((pattern) => pattern.source.replace(/\\\//g, "/")),
757
- }),
758
- })),
759
- }),
760
- },
761
- ])),
762
- ...(policy.alwaysRequested.length === 0
763
- ? {}
764
- : { alwaysRequested: [...policy.alwaysRequested] }),
765
- ...(policy.tolerated.length === 0
766
- ? {}
767
- : { tolerated: [...policy.tolerated] }),
768
- access: [...capability.access],
769
- ...(capability.readMethods === undefined
770
- ? {}
771
- : { readMethods: [...capability.readMethods] }),
772
- ...(capability.writeMethods === undefined
773
- ? {}
774
- : { writeMethods: [...capability.writeMethods] }),
775
514
  ...(capability.hint === undefined ? {} : { hint: capability.hint }),
776
515
  ...(capability.example === undefined
777
516
  ? {}
@@ -779,11 +518,3 @@ export function oauthSpecOf(id, capability) {
779
518
  ...(capability.tags === undefined ? {} : { tags: [...capability.tags] }),
780
519
  };
781
520
  }
782
- /** The methods a held-token write may use on `capability` beyond its reads. */
783
- export function oauthWriteMethods(capability) {
784
- return capability.writeMethods ?? DEFAULT_WRITE_METHODS;
785
- }
786
- /** The methods a read grant may use on `capability` (GET and HEAD unless the spec says). */
787
- export function oauthReadMethods(capability) {
788
- return capability.readMethods ?? ["GET", "HEAD"];
789
- }