@specbook/ui 0.1.0 → 0.3.0

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 (75) hide show
  1. package/dist-viewer/THIRD-PARTY-NOTICES.txt +581 -0
  2. package/dist-viewer/assets/Host-Cq-4gDuM.js +41 -0
  3. package/dist-viewer/assets/index-BBlFcQUE.js +1 -0
  4. package/dist-viewer/assets/main-CSkB-O_8.js +14 -0
  5. package/dist-viewer/assets/mount-DfwluJy4.js +2 -0
  6. package/dist-viewer/assets/panel-9eYU7Gz6.js +1 -0
  7. package/dist-viewer/assets/panel-hNOojwiM.css +1 -0
  8. package/dist-viewer/assets/registry-DIHX11uY.js +2 -0
  9. package/dist-viewer/assets/worker-BmvGpsJS.js +181 -0
  10. package/dist-viewer/index.html +4 -4
  11. package/dist-viewer/specbook.js +1 -1
  12. package/package.json +7 -5
  13. package/src/app.tsx +71 -13
  14. package/src/branding.ts +12 -4
  15. package/src/components/Catalog.tsx +128 -51
  16. package/src/components/EnvBadge.tsx +24 -4
  17. package/src/components/EnvironmentMenu.tsx +237 -127
  18. package/src/components/Fields.tsx +18 -1
  19. package/src/components/NewEnvironmentDialog.tsx +118 -0
  20. package/src/components/Palette.tsx +7 -2
  21. package/src/custom-environments.ts +63 -0
  22. package/src/embed/panel.tsx +287 -44
  23. package/src/embed/styles.ts +2 -0
  24. package/src/embed/target.ts +82 -0
  25. package/src/embed/theme.ts +75 -0
  26. package/src/embed.ts +44 -15
  27. package/src/engine.ts +416 -32
  28. package/src/environment-list.ts +16 -0
  29. package/src/main.tsx +14 -1
  30. package/src/nav.ts +12 -1
  31. package/src/oauth-callback.ts +87 -0
  32. package/src/oauth.ts +195 -0
  33. package/src/panel/Body.tsx +27 -4
  34. package/src/panel/Checks.tsx +79 -0
  35. package/src/panel/CodeSample.tsx +16 -8
  36. package/src/panel/Combo.tsx +52 -13
  37. package/src/panel/ControlsGrid.tsx +54 -9
  38. package/src/panel/ErrorBoundary.tsx +36 -0
  39. package/src/panel/Host.tsx +14 -2
  40. package/src/panel/OAuthSignIn.tsx +167 -0
  41. package/src/panel/Panel.tsx +50 -13
  42. package/src/panel/ParamGrid.tsx +15 -8
  43. package/src/panel/RequestPanel.tsx +93 -36
  44. package/src/panel/RequestView.tsx +2 -1
  45. package/src/panel/ResponseView.tsx +25 -14
  46. package/src/panel/SecretsBar.tsx +43 -9
  47. package/src/panel/SequenceDocs.tsx +7 -6
  48. package/src/panel/SequencePanel.tsx +124 -32
  49. package/src/panel/announce.ts +17 -0
  50. package/src/panel/icons.tsx +9 -0
  51. package/src/panel/short.ts +24 -0
  52. package/src/routing.ts +0 -9
  53. package/src/runtime.ts +19 -1
  54. package/src/secrets.ts +4 -2
  55. package/src/storage.ts +58 -0
  56. package/src/store.ts +10 -0
  57. package/src/styles/app.css +69 -6
  58. package/src/styles/embed.css +116 -0
  59. package/src/styles/panel.css +277 -2
  60. package/src/viewer/embed.ts +5 -0
  61. package/src/viewer/main.tsx +5 -1
  62. package/src/viewer/manifests.ts +2 -0
  63. package/src/viewer/protocol.ts +9 -0
  64. package/src/viewer/registry-data.ts +4 -0
  65. package/src/viewer/registry.ts +48 -3
  66. package/src/viewer/worker.ts +20 -7
  67. package/src/virtual.d.ts +8 -0
  68. package/dist-viewer/assets/Host-DdTTHeXU.js +0 -37
  69. package/dist-viewer/assets/index-DnYdlDXQ.js +0 -1
  70. package/dist-viewer/assets/main-uV4JzQPd.js +0 -14
  71. package/dist-viewer/assets/mount-B-qFOcWp.js +0 -2
  72. package/dist-viewer/assets/panel-CoQ6iQpX.js +0 -1
  73. package/dist-viewer/assets/panel-Coi9H1nv.css +0 -1
  74. package/dist-viewer/assets/registry-DI74Wtj8.js +0 -2
  75. package/dist-viewer/assets/worker-Dw3Ps3hk.js +0 -181
package/src/embed.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Target } from './embed/panel'
1
+ import { parseTarget, type Target } from './embed/target'
2
+ import { followTheme } from './embed/theme'
2
3
  import tokens from './styles/tokens.css?inline'
3
4
 
4
5
  const css = `button {
@@ -12,14 +13,27 @@ const css = `button {
12
13
  color: var(--sb-accent);
13
14
  }
14
15
  button:hover { background: var(--sb-surface-raised); }
15
- button:focus-visible { outline: 2px solid var(--sb-focus-ring); }`
16
+ button:focus-visible { outline: 2px solid var(--sb-focus-ring); }
17
+ @media (pointer: coarse) { button { min-height: 44px; min-width: 44px; } }`
18
+
19
+ let opened = false
16
20
 
17
21
  /** The panel (editor, grammars, runner) is a separate chunk fetched on first click. */
18
- async function open(target: Target) {
22
+ export async function open(target: Target, trigger?: HTMLElement): Promise<void> {
19
23
  const { show } = await import('./embed/panel')
20
- show(target)
24
+ opened = true
25
+ show(target, trigger)
26
+ }
27
+
28
+ /** Closes the panel, if it is open. */
29
+ export async function close(): Promise<void> {
30
+ if (!opened) return
31
+ const { close: closePanel } = await import('./embed/panel')
32
+ closePanel()
21
33
  }
22
34
 
35
+ const nameOf = (t: Target) => t.sequence ?? t.request ?? t.operation ?? ''
36
+
23
37
  class SpecbookPlay extends HTMLElement {
24
38
  target: Target = {}
25
39
 
@@ -33,29 +47,44 @@ class SpecbookPlay extends HTMLElement {
33
47
  b.type = 'button'
34
48
  b.part.add('play')
35
49
  b.textContent = '▶ Play'
36
- b.addEventListener('click', () => open(this.target))
50
+ b.setAttribute('aria-haspopup', 'dialog')
51
+ b.setAttribute('aria-expanded', 'false')
52
+ b.setAttribute('aria-label', `Play ${nameOf(this.target)}`.trim())
53
+ b.addEventListener('click', () => open(this.target, this))
37
54
  root.append(b)
55
+ followTheme(this, this.target.theme)
38
56
  }
39
57
  }
40
58
 
41
59
  customElements.define('specbook-play', SpecbookPlay)
42
60
 
61
+ /** A button or a link that carries the attribute is the trigger itself; anything else gets a Play button beside it. */
62
+ const isTrigger = (el: HTMLElement) => el.matches('button, a[href], [role="button"]')
63
+
43
64
  function attach(el: HTMLElement) {
44
- if (el.dataset.specbookReady) return
65
+ if (el.dataset.specbookReady !== undefined) return
45
66
  el.dataset.specbookReady = ''
46
- const play = document.createElement('specbook-play') as SpecbookPlay
47
- play.target = {
48
- service: el.dataset.specbook || undefined,
49
- operation: el.dataset.operation,
50
- sequence: el.dataset.sequence,
51
- example: el.dataset.example,
52
- tab: el.dataset.tab === 'docs' ? 'docs' : undefined,
67
+ const target = parseTarget(el)
68
+ if (!target) return
69
+ if (isTrigger(el)) {
70
+ el.setAttribute('aria-haspopup', 'dialog')
71
+ if (!el.hasAttribute('aria-expanded')) el.setAttribute('aria-expanded', 'false')
72
+ el.addEventListener('click', (e) => {
73
+ e.preventDefault()
74
+ open(target, el)
75
+ })
76
+ return
53
77
  }
78
+ const play = document.createElement('specbook-play') as SpecbookPlay
79
+ play.target = target
54
80
  el.append(play)
55
81
  }
56
82
 
57
- const scan = (root: ParentNode) =>
58
- root.querySelectorAll<HTMLElement>('[data-specbook]:not([data-specbook-ready])').forEach(attach)
83
+ const SELECTOR = ['try', 'flow']
84
+ .map((a) => `[data-specbook-${a}]:not([data-specbook-ready])`)
85
+ .join(',')
86
+
87
+ const scan = (root: ParentNode) => root.querySelectorAll<HTMLElement>(SELECTOR).forEach(attach)
59
88
 
60
89
  scan(document)
61
90
  // SPA docs add content after load
package/src/engine.ts CHANGED
@@ -1,12 +1,23 @@
1
1
  // Run orchestration for the UI. Pure TS (no DOM) so it can be unit-tested with a fake registry.
2
2
  import {
3
+ AddressError,
3
4
  type AuthProfile,
5
+ absoluteBase,
6
+ addressUses,
7
+ baseUrlFor,
4
8
  createFetchTransport,
5
9
  createMockTransport,
10
+ type DiscoveredEndpoints,
6
11
  deepMerge,
7
12
  type Example,
13
+ environmentNames,
14
+ environmentScope,
8
15
  installFaker,
16
+ isDiscovery,
17
+ type OAuthSettings,
9
18
  type OpMeta,
19
+ oauthFor,
20
+ parseDiscovery,
10
21
  prepareFaker,
11
22
  type RequestDef,
12
23
  type RunEvent,
@@ -17,11 +28,24 @@ import {
17
28
  type SpecbookConfig,
18
29
  type Transport,
19
30
  usesFaker,
31
+ withMock,
20
32
  } from '@specbook/core'
33
+ import type { EnvironmentStore } from './custom-environments'
21
34
  import { type ExtensionBridge, extensionTransport } from './extension'
22
- import { absoluteBase, routingTransport, withPageSecrets } from './routing'
35
+ import { routingTransport, withPageSecrets } from './routing'
23
36
  import { originOf, type SecretStore, secretStore } from './secrets'
24
37
 
38
+ /**
39
+ * The reserved service name a sign-in's token exchange is addressed to. The dev server's proxy answers
40
+ * `auth:<profile>` from the profile's own `oauth.tokenUrl` (the same prefix lives in `@specbook/vite`).
41
+ */
42
+ const AUTH_SERVICE_PREFIX = 'auth:'
43
+ const authService = (profile: string) => `${AUTH_SERVICE_PREFIX}${encodeURIComponent(profile)}`
44
+ /** Likewise for an OpenID Connect discovery document, whose host may differ from the token endpoint's. */
45
+ const DISCOVERY_SERVICE_PREFIX = 'discovery:'
46
+ const discoveryService = (profile: string) =>
47
+ `${DISCOVERY_SERVICE_PREFIX}${encodeURIComponent(profile)}`
48
+
25
49
  export interface ExampleEntry {
26
50
  name: string
27
51
  source: 'generated' | 'project'
@@ -61,7 +85,7 @@ export interface RunInput {
61
85
  version: number
62
86
  /** Forced mock status per run (mock env only). */
63
87
  mockStatus?: number
64
- params?: Record<string, unknown>
88
+ inputs?: Record<string, unknown>
65
89
  vars?: Record<string, unknown>
66
90
  onEvent?: (e: RunEvent) => void
67
91
  onInput?: Parameters<typeof runSequence>[1]['onInput']
@@ -73,13 +97,46 @@ export interface RunInput {
73
97
  fileDir?: string
74
98
  }
75
99
 
100
+ /** One variable of an environment as the settings show it. */
101
+ export interface VariableRow {
102
+ name: string
103
+ value: string
104
+ /** where the value comes from: the config's defaults or the environment's section, a file, this browser */
105
+ source: 'default' | 'config' | 'file' | 'browser'
106
+ /** what the variable falls back to when its own value is removed; undefined when it has no other */
107
+ fallback?: string
108
+ /** whether the value can be edited here */
109
+ editable: boolean
110
+ }
111
+
112
+ export interface EnvironmentView {
113
+ rows: VariableRow[]
114
+ /** where each service's calls go in this environment, or why they cannot */
115
+ addresses: { service: string; url?: string; error?: string }[]
116
+ /** which services' addresses and profiles' sign-in fields read each variable */
117
+ uses: ReturnType<typeof addressUses>
118
+ /** a browser-made environment: it can be deleted */
119
+ removable: boolean
120
+ }
121
+
76
122
  export interface EngineOptions {
77
123
  /** The bridge to the browser extension when it is installed and enabled for this page. */
78
124
  extension?: () => Promise<ExtensionBridge | undefined>
79
125
  /** Where the person's typed secrets are kept (the page's store by default). */
80
126
  secrets?: SecretStore
127
+ /** Environments made in this browser (a static page has no files to write them to). */
128
+ environmentStore?: EnvironmentStore
81
129
  }
82
130
 
131
+ const ENV_NAME = /^[A-Za-z0-9_-]+$/
132
+ const VAR_NAME = /^[A-Za-z_][\w.-]*$/
133
+ const hasTemplate = (v: unknown): boolean =>
134
+ typeof v === 'string'
135
+ ? /\{\{/.test(v)
136
+ : v !== null && typeof v === 'object' && Object.values(v).some(hasTemplate)
137
+ const text = (v: unknown) =>
138
+ v !== null && typeof v === 'object' ? JSON.stringify(v) : String(v ?? '')
139
+
83
140
  export function createEngine(reg: Registry, opts: EngineOptions = {}) {
84
141
  const mockLoader = {
85
142
  statuses: (service: string, opId: string) =>
@@ -95,6 +152,11 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
95
152
  return (await reg.loadConfig(version)).default as SpecbookConfig
96
153
  }
97
154
 
155
+ const isStaticPage = () => reg.mode !== 'dev'
156
+ const browserEnvs = () => (isStaticPage() ? (opts.environmentStore?.all() ?? {}) : {})
157
+ // dev: environments created this session, until the registry reloads with their files
158
+ const createdHere = new Set<string>()
159
+
98
160
  /** Environments the config marks `production` or `sandbox`; the rest are judged by their name. */
99
161
  async function environmentRisks(
100
162
  version: number,
@@ -105,20 +167,38 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
105
167
  const risk = (e as { risk?: string } | undefined)?.risk
106
168
  if (risk === 'production' || risk === 'sandbox') out[name] = risk
107
169
  }
170
+ // a copy is as careful as what it was copied from
171
+ for (const [name, b] of Object.entries(browserEnvs()))
172
+ if (b.from && out[b.from] && !out[name]) out[name] = out[b.from] as 'production' | 'sandbox'
173
+ return out
174
+ }
175
+
176
+ /** One line per environment that has a description, for the pickers' tooltips. */
177
+ async function environmentNotes(version: number): Promise<Record<string, string>> {
178
+ const c = await config(version)
179
+ const out: Record<string, string> = {}
180
+ for (const [name, e] of Object.entries(c.environments ?? {})) {
181
+ const note = (e as { description?: string } | undefined)?.description
182
+ if (note) out[name] = note
183
+ }
184
+ for (const [name, b] of Object.entries(browserEnvs()))
185
+ if (b.from) out[name] = `copy of ${b.from}, kept in this browser`
108
186
  return out
109
187
  }
110
188
 
111
189
  async function environments(version: number): Promise<string[]> {
112
190
  const c = await config(version)
113
- const envs = new Set<string>(['mock'])
114
- for (const s of Object.values(c.services ?? {}))
115
- for (const e of Object.keys(s.baseUrl ?? {})) envs.add(e)
116
- for (const e of Object.keys(reg.environmentVars ?? {})) envs.add(e)
117
- for (const e of Object.keys(c.environments ?? {})) envs.add(e)
118
- return [...envs]
191
+ return environmentNames(
192
+ { services: c.services ?? {}, environments: c.environments },
193
+ [...Object.keys(reg.environmentVars ?? {}), ...createdHere],
194
+ Object.keys(browserEnvs()),
195
+ )
119
196
  }
120
197
 
121
- /** The environment's own variables: read live from the dev server, baked in for static builds. */
198
+ /**
199
+ * The environment's own variables: read live from the dev server; baked in for a static build, with
200
+ * the ones this browser holds over them.
201
+ */
122
202
  async function environmentVars(env: string): Promise<Record<string, string>> {
123
203
  if (reg.mode === 'dev') {
124
204
  try {
@@ -128,7 +208,166 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
128
208
  /* fall back to what was baked in */
129
209
  }
130
210
  }
131
- return reg.environmentVars?.[env] ?? {}
211
+ return { ...reg.environmentVars?.[env], ...browserEnvs()[env]?.vars }
212
+ }
213
+
214
+ /** What an address is rendered with: defaults, the environment's own settings, its file or browser copy. */
215
+ async function scopeOf(env: string, version: number): Promise<Record<string, unknown>> {
216
+ return environmentScope(await config(version), env, await environmentVars(env))
217
+ }
218
+
219
+ /** Where `service`'s calls go in `env`; undefined when it has no address there. Throws an AddressError. */
220
+ async function baseUrlOf(service: string, env: string, version: number) {
221
+ return baseUrlFor(await config(version), service, env, await scopeOf(env, version))
222
+ }
223
+
224
+ /** A profile's sign-in settings with its address fields rendered for `env`. */
225
+ async function oauthOf(
226
+ profile: string,
227
+ env: string,
228
+ version: number,
229
+ ): Promise<OAuthSettings | undefined> {
230
+ const oauth = ((await config(version)).auth?.[profile] as AuthProfile | undefined)?.oauth
231
+ // nothing to render, so nothing to ask the dev server for
232
+ if (!oauth || !hasTemplate(oauth)) return oauth
233
+ return oauthFor(oauth, await scopeOf(env, version), profile)
234
+ }
235
+
236
+ /**
237
+ * Addresses for a config the caller already holds (a page reading `engine.config` once for several
238
+ * services): the same rendering as `baseUrlOf` / `oauthOf`, with an address that cannot be worked out
239
+ * left undefined. The environment's variables are fetched only when something is written as a template.
240
+ */
241
+ async function addressesIn(cfg: SpecbookConfig, env: string) {
242
+ const templated =
243
+ Object.values(cfg.services ?? {}).some((s) => hasTemplate(s.baseUrl)) ||
244
+ Object.values(cfg.auth ?? {}).some((p) => hasTemplate((p as AuthProfile).oauth))
245
+ const scope = environmentScope(cfg, env, templated ? await environmentVars(env) : {})
246
+ return {
247
+ baseUrl(service: string): string | undefined {
248
+ try {
249
+ return baseUrlFor(cfg, service, env, scope)
250
+ } catch {
251
+ return undefined
252
+ }
253
+ },
254
+ oauth(profile: string): OAuthSettings | undefined {
255
+ const oauth = (cfg.auth?.[profile] as AuthProfile | undefined)?.oauth
256
+ try {
257
+ return oauth ? oauthFor(oauth, scope, profile) : undefined
258
+ } catch {
259
+ return undefined
260
+ }
261
+ },
262
+ }
263
+ }
264
+
265
+ /** The environment's variables as the settings show them, and where each service's calls go. */
266
+ async function environmentView(env: string, version: number): Promise<EnvironmentView> {
267
+ const c = await config(version)
268
+ const defaults = c.vars ?? {}
269
+ const section =
270
+ (c.environments?.[env] as { vars?: Record<string, unknown> } | undefined)?.vars ?? {}
271
+ const stored = browserEnvs()[env]
272
+ const baked = isStaticPage() ? (reg.environmentVars?.[env] ?? {}) : await environmentVars(env)
273
+ const browser = stored?.vars ?? {}
274
+ const names = [
275
+ ...new Set([
276
+ ...Object.keys(defaults),
277
+ ...Object.keys(section),
278
+ ...Object.keys(baked),
279
+ ...Object.keys(browser),
280
+ ]),
281
+ ]
282
+ const editable = reg.mode === 'dev' || !!opts.environmentStore
283
+ const rows: VariableRow[] = names.map((name) => {
284
+ const inherited = name in section ? section[name] : defaults[name]
285
+ const own = name in browser ? 'browser' : name in baked ? 'file' : undefined
286
+ const value = name in browser ? browser[name] : name in baked ? baked[name] : inherited
287
+ const fallbackSource = name in browser && name in baked ? baked[name] : inherited
288
+ return {
289
+ name,
290
+ value: text(value),
291
+ source: own ?? (name in section ? 'config' : 'default'),
292
+ fallback: own && fallbackSource !== undefined ? text(fallbackSource) : undefined,
293
+ editable,
294
+ }
295
+ })
296
+ const scope = environmentScope(c, env, { ...baked, ...browser })
297
+ const addresses: EnvironmentView['addresses'] = []
298
+ if (env !== 'mock')
299
+ for (const service of Object.keys(c.services ?? {})) {
300
+ try {
301
+ const url = baseUrlFor(c, service, env, scope)
302
+ addresses.push({ service, ...(url ? { url } : {}) })
303
+ } catch (e) {
304
+ if (!(e instanceof AddressError)) throw e
305
+ addresses.push({ service, error: e.message })
306
+ }
307
+ }
308
+ return { rows, addresses, uses: addressUses(c), removable: !!stored?.from }
309
+ }
310
+
311
+ /** Whether a person can make environments here: the dev server writes a file, a static page keeps it. */
312
+ const canCreateEnvironment = () => reg.mode === 'dev' || !!opts.environmentStore
313
+
314
+ /** A new environment holding a copy of `from`'s own variables (never its secrets). */
315
+ async function createEnvironment(name: string, from: string, version: number): Promise<void> {
316
+ if (!ENV_NAME.test(name))
317
+ throw new Error('Use letters, digits, "-" and "_" in an environment name')
318
+ if (name === 'mock' || (await environments(version)).includes(name))
319
+ throw new Error(`"${name}" already exists`)
320
+ if (reg.mode === 'dev') {
321
+ const r = await fetch('/__specbook/environment', {
322
+ method: 'POST',
323
+ headers: { 'content-type': 'application/json' },
324
+ body: JSON.stringify({ name, from }),
325
+ })
326
+ if (!r.ok)
327
+ throw new Error(((await r.json()) as { error?: string }).error ?? 'could not create')
328
+ createdHere.add(name)
329
+ return
330
+ }
331
+ if (!opts.environmentStore) throw new Error('This page has nowhere to keep a new environment')
332
+ const c = await config(version)
333
+ const section =
334
+ (c.environments?.[from] as { vars?: Record<string, unknown> } | undefined)?.vars ?? {}
335
+ const own = { ...section, ...(await environmentVars(from)) }
336
+ opts.environmentStore.save(name, {
337
+ from,
338
+ vars: Object.fromEntries(
339
+ Object.entries(own)
340
+ .filter(([, v]) => v !== null && typeof v !== 'object')
341
+ .map(([k, v]) => [k, String(v)]),
342
+ ),
343
+ })
344
+ }
345
+
346
+ /** Sets (or, with `null`, removes) one of an environment's own variables. */
347
+ async function setVariable(env: string, name: string, value: string | null): Promise<void> {
348
+ if (!VAR_NAME.test(name)) throw new Error(`"${name}" is not a variable name`)
349
+ if (reg.mode === 'dev') {
350
+ const r = await fetch('/__specbook/var', {
351
+ method: 'POST',
352
+ headers: { 'content-type': 'application/json' },
353
+ body: JSON.stringify({ env, name, value }),
354
+ })
355
+ if (!r.ok) throw new Error(((await r.json()) as { error?: string }).error ?? 'save failed')
356
+ return
357
+ }
358
+ const store = opts.environmentStore
359
+ if (!store) throw new Error('This page has nowhere to keep a variable')
360
+ const current = store.all()[env] ?? { vars: {} }
361
+ const vars = { ...current.vars }
362
+ if (value === null) delete vars[name]
363
+ else vars[name] = value
364
+ if (!current.from && Object.keys(vars).length === 0) store.remove(env)
365
+ else store.save(env, { ...current, vars })
366
+ }
367
+
368
+ /** Forgets an environment made in this browser. */
369
+ function deleteEnvironment(env: string): void {
370
+ opts.environmentStore?.remove(env)
132
371
  }
133
372
 
134
373
  /** Names of the variables the project already supplies in `env`: config, per-environment and env files. */
@@ -147,14 +386,30 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
147
386
  * One transport per run, routing each request by its service (`routeFor`). Typed values are bound to
148
387
  * the API's origin first; placeholders left over are filled by the dev server or reported as missing.
149
388
  */
150
- async function transportFor(env: string, proxyBase: string, version: number): Promise<Transport> {
389
+ async function transportFor(
390
+ env: string,
391
+ proxyBase: string,
392
+ version: number,
393
+ /** extra services by name -> base URL: a sign-in's token endpoint, which is not a configured service */
394
+ extra: Record<string, string> = {},
395
+ ): Promise<Transport> {
151
396
  if (env === 'mock') return createMockTransport({ loader: mockLoader })
152
397
  const cfg = await config(version)
398
+ // the environment's variables are only fetched when an address is written as a template
399
+ const templated = Object.values(cfg.services ?? {}).some((s) => hasTemplate(s.baseUrl))
400
+ const scope = templated ? await scopeOf(env, version) : {}
153
401
  const pageHref = globalThis.location?.href
154
402
  const pageOrigin = globalThis.location?.origin
155
403
  const baseOf = (service: string) => {
156
- const url = cfg.services?.[service]?.baseUrl?.[env]
157
- if (!url) throw new Error(`No base URL for ${service} in the "${env}" environment`)
404
+ if (extra[service]) return extra[service]
405
+ let url: string | undefined
406
+ try {
407
+ url = baseUrlFor(cfg, service, env, scope)
408
+ } catch (e) {
409
+ if (e instanceof AddressError) throw new Error(e.message)
410
+ throw e
411
+ }
412
+ if (!url) throw new Error(`No base URL for ${service} in ${env}`)
158
413
  return absoluteBase(url, pageHref)
159
414
  }
160
415
  const hasProxy = reg.mode !== 'static'
@@ -194,6 +449,14 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
194
449
  return await inner(req, signal)
195
450
  } catch (e) {
196
451
  // a blocked cross-origin call surfaces as a bare TypeError("Failed to fetch")
452
+ if (
453
+ e instanceof TypeError &&
454
+ (req.service.startsWith(AUTH_SERVICE_PREFIX) ||
455
+ req.service.startsWith(DISCOVERY_SERVICE_PREFIX))
456
+ )
457
+ throw new Error(
458
+ `The browser could not reach the sign-in provider (${extra[req.service] ?? 'unknown'}). It probably does not allow calls from this page (CORS). Embedding the viewer on the API's origin does not help when the sign-in server is on another host; the browser extension can make the call. You can still paste an access token.`,
459
+ )
197
460
  if (e instanceof TypeError)
198
461
  throw new Error(
199
462
  `The browser could not reach ${req.service} in "${env}". The API probably does not allow calls from this page (CORS). It works when the viewer runs on the API's own origin: embed it in the API's docs route ("Embed the viewer" in the README). Mock data keeps working.`,
@@ -219,6 +482,111 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
219
482
  }
220
483
  }
221
484
 
485
+ type SignInEndpoints = DiscoveredEndpoints & { scopes: Record<string, string> }
486
+ const discovered = new Map<string, Promise<DiscoveredEndpoints>>()
487
+
488
+ /**
489
+ * Where a profile signs in: the endpoints its config writes out, or the ones its OpenID Connect
490
+ * discovery document names. The document is fetched the way any other call from the page is (through
491
+ * the dev server's proxy when there is one) and remembered for the life of the page.
492
+ */
493
+ async function oauthEndpoints(i: {
494
+ env: string
495
+ version: number
496
+ profile: string
497
+ proxyBase?: string
498
+ signal?: AbortSignal
499
+ }): Promise<SignInEndpoints> {
500
+ if (i.env === 'mock') throw new Error('Sign in needs a live environment, not mock')
501
+ let oauth: OAuthSettings | undefined
502
+ try {
503
+ oauth = await oauthOf(i.profile, i.env, i.version)
504
+ } catch (e) {
505
+ if (e instanceof AddressError) throw new Error(e.message)
506
+ throw e
507
+ }
508
+ if (!oauth) throw new Error(`"${i.profile}" has no sign-in configured`)
509
+ if (!isDiscovery(oauth))
510
+ return {
511
+ authorizationUrl: oauth.authorizationUrl,
512
+ tokenUrl: oauth.tokenUrl,
513
+ scopes: oauth.scopes ?? {},
514
+ }
515
+
516
+ const url = new URL(oauth.openIdConnectUrl)
517
+ let found = discovered.get(url.href)
518
+ if (!found) {
519
+ found = (async () => {
520
+ const service = discoveryService(i.profile)
521
+ const send = await transportFor(i.env, i.proxyBase ?? '/__specbook/proxy', i.version, {
522
+ [service]: url.origin,
523
+ })
524
+ const res = await send(
525
+ {
526
+ service,
527
+ opId: 'oidc-discovery',
528
+ method: 'GET',
529
+ url: url.pathname,
530
+ query: Object.fromEntries(url.searchParams),
531
+ headers: { accept: 'application/json' },
532
+ encoding: 'none',
533
+ },
534
+ i.signal,
535
+ )
536
+ if (res.status >= 400)
537
+ throw new Error(`The provider's discovery document answered ${res.status}`)
538
+ return parseDiscovery(res.body)
539
+ })()
540
+ discovered.set(url.href, found)
541
+ // a failure is not remembered: the next click tries again
542
+ found.catch(() => discovered.delete(url.href))
543
+ }
544
+ const endpoints = await found
545
+ return {
546
+ ...endpoints,
547
+ scopes: oauth.scopes && Object.keys(oauth.scopes).length ? oauth.scopes : endpoints.scopes,
548
+ }
549
+ }
550
+
551
+ /**
552
+ * A sign-in's code exchange, sent the way any other call from this page is: through the dev server's
553
+ * proxy when there is one, else direct or through the extension. The token endpoint comes from the
554
+ * profile in the config (or its discovery document), never from the caller, so the proxy only ever
555
+ * forwards to the project's own.
556
+ */
557
+ async function oauthExchange(i: {
558
+ env: string
559
+ version: number
560
+ profile: string
561
+ fields: Record<string, string>
562
+ proxyBase?: string
563
+ signal?: AbortSignal
564
+ }): Promise<unknown> {
565
+ const { tokenUrl } = await oauthEndpoints(i)
566
+ const token = new URL(tokenUrl)
567
+ const service = authService(i.profile)
568
+ const send = await transportFor(i.env, i.proxyBase ?? '/__specbook/proxy', i.version, {
569
+ [service]: token.origin,
570
+ })
571
+ const res = await send(
572
+ {
573
+ service,
574
+ opId: 'oauth-token',
575
+ method: 'POST',
576
+ url: token.pathname,
577
+ query: Object.fromEntries(token.searchParams),
578
+ headers: { accept: 'application/json' },
579
+ body: i.fields,
580
+ encoding: 'form',
581
+ },
582
+ i.signal,
583
+ )
584
+ const body = res.body as { error?: unknown } | undefined
585
+ if (res.status >= 400 && typeof body?.error !== 'string')
586
+ throw new Error(`The token endpoint answered ${res.status}`)
587
+ return res.body
588
+ }
589
+
222
590
  const norm = (p: string) => p.replace(/^\.\//, '')
223
591
 
224
592
  /** Display order: the author's examples first, generated ones last. */
@@ -255,13 +623,9 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
255
623
  )
256
624
  return {
257
625
  env: i.env,
258
- vars: {
259
- ...(c.vars ?? {}),
260
- ...(c.environments?.[i.env]?.vars ?? {}),
261
- ...(await environmentVars(i.env)),
262
- },
626
+ vars: await scopeOf(i.env, i.version),
263
627
  overrides: i.vars,
264
- params: i.params,
628
+ inputs: i.inputs,
265
629
  transport: await transportFor(i.env, i.proxyBase ?? '/__specbook/proxy', i.version),
266
630
  // placeholders stay in the request: the dev server's proxy or a static page's transport fills them
267
631
  deferSecrets: true,
@@ -270,6 +634,8 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
270
634
  onEvent: i.onEvent,
271
635
  onInput: i.onInput,
272
636
  signal: i.signal,
637
+ // the project's own, else the runner's default
638
+ timeoutMs: c.timeoutMs,
273
639
  example: (op: { service: string; id: string }, name: string) =>
274
640
  loadExample(`${op.service}/${op.id}`, name, i.version),
275
641
  readFile: (path: string, dir?: string) => readFile(path, dir ?? i.fileDir),
@@ -294,27 +660,31 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
294
660
  return usesFaker([base, data, target.dataPatches, path, query, headers, i.vars])
295
661
  }
296
662
 
663
+ /**
664
+ * Makes faker ready for `locale` (a bundled module offers each locale as its own chunk, so only the ones it falls back
665
+ * through are fetched). Generated values need it before a run: a form that offers one asks for it when it opens.
666
+ */
667
+ async function fakerReady(version: number, locale?: string, seed?: number): Promise<boolean> {
668
+ if (!reg.loadFaker) return false
669
+ const [mod, cfg] = await Promise.all([reg.loadFaker(version), config(version)])
670
+ const ready = await prepareFaker(mod, cfg.faker, locale)
671
+ installFaker(ready as never, cfg.faker, { locale, seed })
672
+ return true
673
+ }
674
+
297
675
  async function run(
298
676
  target: RequestDef<any, any, any> | Sequence<any, any>,
299
677
  i: RunInput,
300
678
  ): Promise<RunResult> {
301
679
  // `init` generators call faker, so it must be ready before any run unless we can see it isn't used.
302
- if (reg.loadFaker && (await mayUseFaker(target, i))) {
303
- const [mod, cfg] = await Promise.all([reg.loadFaker(i.version), config(i.version)])
304
- // a bundled module offers each locale as its own chunk; fetch only the ones this run falls back through
305
- const ready = await prepareFaker(mod, cfg.faker, i.locale)
306
- installFaker(ready as never, cfg.faker, { locale: i.locale, seed: i.seed })
307
- }
680
+ if (reg.loadFaker && (await mayUseFaker(target, i)))
681
+ await fakerReady(i.version, i.locale, i.seed)
308
682
  const o = await baseOptions(i)
309
683
  if (target.kind === 'sequence') return runSequence(target, o)
310
- const def =
311
- i.mockStatus !== undefined && i.env === 'mock'
312
- ? target.with({ mock: { status: i.mockStatus } })
313
- : target
314
- return runRequest(def, o)
684
+ return runRequest(target, i.env === 'mock' ? { ...o, mockStatus: i.mockStatus } : o)
315
685
  }
316
686
 
317
- /** `base` keeps its init/capture/assert/mock; `overrides` replace path/query/headers/data. */
687
+ /** `base` keeps its init/capture/assert and mock hook; `overrides` replace path/query/headers/data. */
318
688
  async function operationRequest(
319
689
  key: string,
320
690
  version: number,
@@ -327,7 +697,8 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
327
697
  const op = mod[key.split('/')[1] as string]
328
698
  if (!op) throw new Error(`Operation module for ${key} has no export`)
329
699
  const defined = Object.fromEntries(Object.entries(overrides).filter(([, v]) => v !== undefined))
330
- return op.request({ ...(base?.input ?? {}), ...defined })
700
+ const made = op.request({ ...(base?.input ?? {}), ...defined })
701
+ return base?.mockHook ? withMock(made, base.mockHook) : made
331
702
  }
332
703
 
333
704
  async function operationMeta(key: string, version: number): Promise<OpMeta | undefined> {
@@ -400,8 +771,21 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
400
771
  config,
401
772
  environments,
402
773
  environmentRisks,
774
+ environmentNotes,
775
+ environmentVars,
776
+ environmentView,
777
+ canCreateEnvironment,
778
+ createEnvironment,
779
+ deleteEnvironment,
780
+ setVariable,
781
+ baseUrlOf,
782
+ oauthOf,
783
+ addressesIn,
403
784
  managedSecrets,
785
+ oauthEndpoints,
786
+ oauthExchange,
404
787
  run,
788
+ fakerReady,
405
789
  operationRequest,
406
790
  operationMeta,
407
791
  examplesOf,