@specbook/ui 0.1.0 → 0.2.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 (38) hide show
  1. package/dist-viewer/THIRD-PARTY-NOTICES.txt +581 -0
  2. package/dist-viewer/assets/Host-CSrM9wY1.js +37 -0
  3. package/dist-viewer/assets/index-ez9c20v3.js +1 -0
  4. package/dist-viewer/assets/main-38QGGpNT.js +14 -0
  5. package/dist-viewer/assets/mount-CGL2tOpN.js +2 -0
  6. package/dist-viewer/assets/{panel-Coi9H1nv.css → panel-C-vBZrep.css} +1 -1
  7. package/dist-viewer/assets/panel-C8Ve3LeS.js +1 -0
  8. package/dist-viewer/assets/registry-ChKCFBt1.js +2 -0
  9. package/dist-viewer/assets/{worker-Dw3Ps3hk.js → worker-ClGb0tE1.js} +1 -1
  10. package/dist-viewer/index.html +4 -4
  11. package/dist-viewer/specbook.js +1 -1
  12. package/package.json +5 -4
  13. package/src/app.tsx +7 -2
  14. package/src/components/EnvBadge.tsx +4 -2
  15. package/src/embed/panel.tsx +2 -1
  16. package/src/engine.ts +143 -1
  17. package/src/main.tsx +9 -1
  18. package/src/oauth-callback.ts +87 -0
  19. package/src/oauth.ts +194 -0
  20. package/src/panel/CodeSample.tsx +4 -3
  21. package/src/panel/OAuthSignIn.tsx +163 -0
  22. package/src/panel/Panel.tsx +8 -4
  23. package/src/panel/RequestPanel.tsx +2 -0
  24. package/src/panel/SecretsBar.tsx +35 -2
  25. package/src/store.ts +2 -0
  26. package/src/styles/panel.css +44 -2
  27. package/src/viewer/embed.ts +5 -0
  28. package/src/viewer/main.tsx +5 -1
  29. package/src/viewer/protocol.ts +3 -0
  30. package/src/viewer/registry-data.ts +4 -0
  31. package/src/viewer/registry.ts +11 -2
  32. package/src/viewer/worker.ts +12 -7
  33. package/dist-viewer/assets/Host-DdTTHeXU.js +0 -37
  34. package/dist-viewer/assets/index-DnYdlDXQ.js +0 -1
  35. package/dist-viewer/assets/main-uV4JzQPd.js +0 -14
  36. package/dist-viewer/assets/mount-B-qFOcWp.js +0 -2
  37. package/dist-viewer/assets/panel-CoQ6iQpX.js +0 -1
  38. package/dist-viewer/assets/registry-DI74Wtj8.js +0 -2
package/src/engine.ts CHANGED
@@ -3,10 +3,13 @@ import {
3
3
  type AuthProfile,
4
4
  createFetchTransport,
5
5
  createMockTransport,
6
+ type DiscoveredEndpoints,
6
7
  deepMerge,
7
8
  type Example,
8
9
  installFaker,
10
+ isDiscovery,
9
11
  type OpMeta,
12
+ parseDiscovery,
10
13
  prepareFaker,
11
14
  type RequestDef,
12
15
  type RunEvent,
@@ -22,6 +25,17 @@ import { type ExtensionBridge, extensionTransport } from './extension'
22
25
  import { absoluteBase, routingTransport, withPageSecrets } from './routing'
23
26
  import { originOf, type SecretStore, secretStore } from './secrets'
24
27
 
28
+ /**
29
+ * The reserved service name a sign-in's token exchange is addressed to. The dev server's proxy answers
30
+ * `auth:<profile>` from the profile's own `oauth.tokenUrl` (the same prefix lives in `@specbook/vite`).
31
+ */
32
+ const AUTH_SERVICE_PREFIX = 'auth:'
33
+ const authService = (profile: string) => `${AUTH_SERVICE_PREFIX}${encodeURIComponent(profile)}`
34
+ /** Likewise for an OpenID Connect discovery document, whose host may differ from the token endpoint's. */
35
+ const DISCOVERY_SERVICE_PREFIX = 'discovery:'
36
+ const discoveryService = (profile: string) =>
37
+ `${DISCOVERY_SERVICE_PREFIX}${encodeURIComponent(profile)}`
38
+
25
39
  export interface ExampleEntry {
26
40
  name: string
27
41
  source: 'generated' | 'project'
@@ -108,6 +122,17 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
108
122
  return out
109
123
  }
110
124
 
125
+ /** One line per environment that has a description, for the pickers' tooltips. */
126
+ async function environmentNotes(version: number): Promise<Record<string, string>> {
127
+ const c = await config(version)
128
+ const out: Record<string, string> = {}
129
+ for (const [name, e] of Object.entries(c.environments ?? {})) {
130
+ const note = (e as { description?: string } | undefined)?.description
131
+ if (note) out[name] = note
132
+ }
133
+ return out
134
+ }
135
+
111
136
  async function environments(version: number): Promise<string[]> {
112
137
  const c = await config(version)
113
138
  const envs = new Set<string>(['mock'])
@@ -147,12 +172,19 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
147
172
  * One transport per run, routing each request by its service (`routeFor`). Typed values are bound to
148
173
  * the API's origin first; placeholders left over are filled by the dev server or reported as missing.
149
174
  */
150
- async function transportFor(env: string, proxyBase: string, version: number): Promise<Transport> {
175
+ async function transportFor(
176
+ env: string,
177
+ proxyBase: string,
178
+ version: number,
179
+ /** extra services by name -> base URL: a sign-in's token endpoint, which is not a configured service */
180
+ extra: Record<string, string> = {},
181
+ ): Promise<Transport> {
151
182
  if (env === 'mock') return createMockTransport({ loader: mockLoader })
152
183
  const cfg = await config(version)
153
184
  const pageHref = globalThis.location?.href
154
185
  const pageOrigin = globalThis.location?.origin
155
186
  const baseOf = (service: string) => {
187
+ if (extra[service]) return extra[service]
156
188
  const url = cfg.services?.[service]?.baseUrl?.[env]
157
189
  if (!url) throw new Error(`No base URL for ${service} in the "${env}" environment`)
158
190
  return absoluteBase(url, pageHref)
@@ -194,6 +226,14 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
194
226
  return await inner(req, signal)
195
227
  } catch (e) {
196
228
  // a blocked cross-origin call surfaces as a bare TypeError("Failed to fetch")
229
+ if (
230
+ e instanceof TypeError &&
231
+ (req.service.startsWith(AUTH_SERVICE_PREFIX) ||
232
+ req.service.startsWith(DISCOVERY_SERVICE_PREFIX))
233
+ )
234
+ throw new Error(
235
+ `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.`,
236
+ )
197
237
  if (e instanceof TypeError)
198
238
  throw new Error(
199
239
  `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 +259,105 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
219
259
  }
220
260
  }
221
261
 
262
+ type SignInEndpoints = DiscoveredEndpoints & { scopes: Record<string, string> }
263
+ const discovered = new Map<string, Promise<DiscoveredEndpoints>>()
264
+
265
+ /**
266
+ * Where a profile signs in: the endpoints its config writes out, or the ones its OpenID Connect
267
+ * discovery document names. The document is fetched the way any other call from the page is (through
268
+ * the dev server's proxy when there is one) and remembered for the life of the page.
269
+ */
270
+ async function oauthEndpoints(i: {
271
+ env: string
272
+ version: number
273
+ profile: string
274
+ proxyBase?: string
275
+ signal?: AbortSignal
276
+ }): Promise<SignInEndpoints> {
277
+ if (i.env === 'mock') throw new Error('Sign in needs a live environment, not mock')
278
+ const oauth = ((await config(i.version)).auth?.[i.profile] as AuthProfile | undefined)?.oauth
279
+ if (!oauth) throw new Error(`"${i.profile}" has no sign-in configured`)
280
+ if (!isDiscovery(oauth))
281
+ return {
282
+ authorizationUrl: oauth.authorizationUrl,
283
+ tokenUrl: oauth.tokenUrl,
284
+ scopes: oauth.scopes ?? {},
285
+ }
286
+
287
+ const url = new URL(oauth.openIdConnectUrl)
288
+ let found = discovered.get(url.href)
289
+ if (!found) {
290
+ found = (async () => {
291
+ const service = discoveryService(i.profile)
292
+ const send = await transportFor(i.env, i.proxyBase ?? '/__specbook/proxy', i.version, {
293
+ [service]: url.origin,
294
+ })
295
+ const res = await send(
296
+ {
297
+ service,
298
+ opId: 'oidc-discovery',
299
+ method: 'GET',
300
+ url: url.pathname,
301
+ query: Object.fromEntries(url.searchParams),
302
+ headers: { accept: 'application/json' },
303
+ encoding: 'none',
304
+ },
305
+ i.signal,
306
+ )
307
+ if (res.status >= 400)
308
+ throw new Error(`The provider's discovery document answered ${res.status}`)
309
+ return parseDiscovery(res.body)
310
+ })()
311
+ discovered.set(url.href, found)
312
+ // a failure is not remembered: the next click tries again
313
+ found.catch(() => discovered.delete(url.href))
314
+ }
315
+ const endpoints = await found
316
+ return {
317
+ ...endpoints,
318
+ scopes: oauth.scopes && Object.keys(oauth.scopes).length ? oauth.scopes : endpoints.scopes,
319
+ }
320
+ }
321
+
322
+ /**
323
+ * A sign-in's code exchange, sent the way any other call from this page is: through the dev server's
324
+ * proxy when there is one, else direct or through the extension. The token endpoint comes from the
325
+ * profile in the config (or its discovery document), never from the caller, so the proxy only ever
326
+ * forwards to the project's own.
327
+ */
328
+ async function oauthExchange(i: {
329
+ env: string
330
+ version: number
331
+ profile: string
332
+ fields: Record<string, string>
333
+ proxyBase?: string
334
+ signal?: AbortSignal
335
+ }): Promise<unknown> {
336
+ const { tokenUrl } = await oauthEndpoints(i)
337
+ const token = new URL(tokenUrl)
338
+ const service = authService(i.profile)
339
+ const send = await transportFor(i.env, i.proxyBase ?? '/__specbook/proxy', i.version, {
340
+ [service]: token.origin,
341
+ })
342
+ const res = await send(
343
+ {
344
+ service,
345
+ opId: 'oauth-token',
346
+ method: 'POST',
347
+ url: token.pathname,
348
+ query: Object.fromEntries(token.searchParams),
349
+ headers: { accept: 'application/json' },
350
+ body: i.fields,
351
+ encoding: 'form',
352
+ },
353
+ i.signal,
354
+ )
355
+ const body = res.body as { error?: unknown } | undefined
356
+ if (res.status >= 400 && typeof body?.error !== 'string')
357
+ throw new Error(`The token endpoint answered ${res.status}`)
358
+ return res.body
359
+ }
360
+
222
361
  const norm = (p: string) => p.replace(/^\.\//, '')
223
362
 
224
363
  /** Display order: the author's examples first, generated ones last. */
@@ -400,7 +539,10 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
400
539
  config,
401
540
  environments,
402
541
  environmentRisks,
542
+ environmentNotes,
403
543
  managedSecrets,
544
+ oauthEndpoints,
545
+ oauthExchange,
404
546
  run,
405
547
  operationRequest,
406
548
  operationMeta,
package/src/main.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { render } from 'preact'
2
2
  import { App } from './app'
3
+ import { handleOAuthCallback } from './oauth-callback'
3
4
  import { applyTheme } from './theme'
4
5
  import 'prism-code-editor/layout.css'
5
6
  import 'prism-code-editor/autocomplete.css'
@@ -9,7 +10,14 @@ import './styles/app.css'
9
10
  import './styles/panel.css'
10
11
 
11
12
  applyTheme()
12
- render(<App />, document.getElementById('app') as HTMLElement)
13
+ // A sign-in's return leg is handed to the tab that started it; this page then has nothing to draw. Under the
14
+ // viewer shell or an embed (which import this) that was settled before they got here, and running it again
15
+ // would strip a host page's own `?code=&state=` that the embed deliberately left alone.
16
+ const underShell =
17
+ (globalThis as { __SPECBOOK_VIEWER__?: unknown }).__SPECBOOK_VIEWER__ !== undefined
18
+ void (underShell ? Promise.resolve(false) : handleOAuthCallback()).then((handled) => {
19
+ if (!handled) render(<App />, document.getElementById('app') as HTMLElement)
20
+ })
13
21
 
14
22
  if (import.meta.hot) {
15
23
  import.meta.hot.on('specbook:registry-changed', () => location.reload())
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The return leg of a sign-in. The identity provider redirects the popup to a page with `?code=&state=`
3
+ * (or `?error=`). Run first on boot: when the tab that started the sign-in is waiting for this `state`
4
+ * it takes the result and this popup closes without drawing the app.
5
+ *
6
+ * Two ways to reach the waiting tab, because each survives what the other does not:
7
+ * - a same-origin `BroadcastChannel`: providers that send `Cross-Origin-Opener-Policy` sever
8
+ * `window.opener`, but a channel still connects the two pages;
9
+ * - `window.opener.postMessage`: a viewer inside an iframe on another site has partitioned storage, so its
10
+ * channel never hears a top-level popup, but its window is still the popup's opener.
11
+ *
12
+ * A stray visit to a URL that merely looks like a callback is told nobody is waiting and carries on as a
13
+ * normal page load. The hosted site's own callback page (`landing/oauth/callback/callback.js`) does the same
14
+ * in plain JS; keep the two in step.
15
+ */
16
+ export const OAUTH_CHANNEL = 'specbook-oauth'
17
+
18
+ export type OAuthMessage =
19
+ | { type: 'callback'; state: string; code?: string; error?: string; description?: string }
20
+ | { type: 'ack'; state: string }
21
+
22
+ /** How long a callback page waits to be told that someone is waiting for it. */
23
+ const ACK_WAIT_MS = 1500
24
+
25
+ export interface CallbackOptions {
26
+ /**
27
+ * Remove `?code=&state=` from the address even when no tab claims it (default). An embedded viewer sets
28
+ * this to false: the page belongs to someone else, and its query may be their own application's sign-in.
29
+ */
30
+ stripWhenUnclaimed?: boolean
31
+ }
32
+
33
+ /** true when this page load was a sign-in's return and has been handed over: do not boot the app. */
34
+ export function handleOAuthCallback(options: CallbackOptions = {}): Promise<boolean> {
35
+ const strip = options.stripWhenUnclaimed ?? true
36
+ const here = globalThis.location
37
+ if (!here || (typeof BroadcastChannel === 'undefined' && !globalThis.opener))
38
+ return Promise.resolve(false)
39
+ const q = new URLSearchParams(here.search)
40
+ const state = q.get('state')
41
+ if (!state || !(q.has('code') || q.has('error'))) return Promise.resolve(false)
42
+
43
+ const message: OAuthMessage = {
44
+ type: 'callback',
45
+ state,
46
+ ...(q.get('code') ? { code: q.get('code') as string } : {}),
47
+ ...(q.get('error') ? { error: q.get('error') as string } : {}),
48
+ ...(q.get('error_description') ? { description: q.get('error_description') as string } : {}),
49
+ }
50
+ // the code and state are single-use and do not belong in history or a copied address
51
+ const clean = () => history.replaceState(null, '', here.pathname + here.hash)
52
+ if (strip) clean()
53
+
54
+ return new Promise((resolve) => {
55
+ const channel =
56
+ typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(OAUTH_CHANNEL)
57
+ let finished = false
58
+ const finish = (handled: boolean) => {
59
+ if (finished) return
60
+ finished = true
61
+ clearTimeout(timer)
62
+ channel?.close()
63
+ removeEventListener('message', onMessage)
64
+ resolve(handled)
65
+ }
66
+ const claimed = () => {
67
+ if (finished) return
68
+ clean()
69
+ finish(true)
70
+ document.body.textContent = 'Signed in. You can close this window.'
71
+ window.close()
72
+ }
73
+ const isAck = (d: OAuthMessage | undefined) => d?.type === 'ack' && d.state === state
74
+ const onMessage = (e: MessageEvent<OAuthMessage>) => {
75
+ if (e.origin === here.origin && isAck(e.data)) claimed()
76
+ }
77
+ const timer = setTimeout(() => finish(false), ACK_WAIT_MS)
78
+ if (channel) channel.onmessage = (e: MessageEvent<OAuthMessage>) => isAck(e.data) && claimed()
79
+ addEventListener('message', onMessage)
80
+ channel?.postMessage(message)
81
+ try {
82
+ window.opener?.postMessage(message, here.origin)
83
+ } catch {
84
+ /* the opener is cross-origin or gone: the channel is the only way */
85
+ }
86
+ })
87
+ }
package/src/oauth.ts ADDED
@@ -0,0 +1,194 @@
1
+ import {
2
+ authorizeUrl,
3
+ createPkce,
4
+ newState,
5
+ OAuthError,
6
+ parseCallback,
7
+ parseTokenResponse,
8
+ tokenRequestFields,
9
+ } from '@specbook/core'
10
+ import { OAUTH_CHANNEL, type OAuthMessage } from './oauth-callback'
11
+
12
+ /**
13
+ * Page side of "Sign in": opens the provider in a popup, waits for the return leg
14
+ * (`oauth-callback.ts`), exchanges the code and hands back the access token. The verifier and the `state`
15
+ * live in this closure only: not in the URL, not in storage, not in a log.
16
+ */
17
+
18
+ /**
19
+ * The address a provider sends the person back to, and the one to register with it.
20
+ *
21
+ * The hosted site declares a stable, unversioned callback page (a `<meta>` its assembly adds to the viewer's
22
+ * `index.html`), so what people register does not change with the viewer's channel (`/v1/`, `/v2/`).
23
+ * Anywhere else (a self-hosted copy, an embed on a host page, the dev server) it is the page's own address.
24
+ */
25
+ export const redirectUri = (): string => {
26
+ const declared = document
27
+ .querySelector<HTMLMetaElement>('meta[name="specbook-oauth-callback"]')
28
+ ?.content.trim()
29
+ if (declared) {
30
+ try {
31
+ return new URL(declared, location.origin).href
32
+ } catch {
33
+ /* not an address: fall back to the page's own */
34
+ }
35
+ }
36
+ return `${location.origin}${location.pathname}`
37
+ }
38
+
39
+ export interface SignInInput {
40
+ authorizationUrl: string
41
+ clientId: string
42
+ scopes: string[]
43
+ /** posts the form to the token endpoint and returns the parsed response body */
44
+ exchange(fields: Record<string, string>, signal?: AbortSignal): Promise<unknown>
45
+ signal?: AbortSignal
46
+ /** how long to wait for the person to finish (default five minutes) */
47
+ timeoutMs?: number
48
+ }
49
+
50
+ /**
51
+ * Call this straight from a click: the popup has to open before anything is awaited or the browser
52
+ * blocks it.
53
+ */
54
+ export async function signIn(
55
+ i: SignInInput,
56
+ ): Promise<{ accessToken: string; expiresIn?: number; scope?: string }> {
57
+ // opened blank and pointed at the provider once the challenge exists, which keeps Safari's
58
+ // user-activation rule happy
59
+ const popup = window.open('', OAUTH_CHANNEL, 'popup,width=520,height=720')
60
+ if (!popup)
61
+ throw new OAuthError(
62
+ 'The browser blocked the sign-in window. Allow pop-ups for this page and try again.',
63
+ )
64
+
65
+ const channel = new BroadcastChannel(OAUTH_CHANNEL)
66
+ let onWindowMessage: ((e: MessageEvent<OAuthMessage>) => void) | undefined
67
+ try {
68
+ const { verifier, challenge } = await createPkce()
69
+ const state = newState()
70
+ const redirect = redirectUri()
71
+
72
+ const returned = new Promise<OAuthMessage & { type: 'callback' }>((resolve, reject) => {
73
+ const timer = setTimeout(
74
+ () => reject(new OAuthError('Sign-in timed out. Try again.')),
75
+ i.timeoutMs ?? 5 * 60_000,
76
+ )
77
+ const stop = () => clearTimeout(timer)
78
+ i.signal?.addEventListener('abort', () => {
79
+ stop()
80
+ reject(new OAuthError('Sign-in was cancelled.'))
81
+ })
82
+ // the return arrives over the channel or from the popup as its opener; both carry the same message,
83
+ // and the one that arrives second is a repeat of the first
84
+ const take = (m: OAuthMessage | undefined, ack: (a: OAuthMessage) => void) => {
85
+ if (m?.type !== 'callback' || m.state !== state) return
86
+ stop()
87
+ ack({ type: 'ack', state })
88
+ resolve(m)
89
+ }
90
+ channel.onmessage = (e: MessageEvent<OAuthMessage>) =>
91
+ take(e.data, (a) => channel.postMessage(a))
92
+ onWindowMessage = (e: MessageEvent<OAuthMessage>) => {
93
+ // only this origin's own callback page: another site cannot hand us a code
94
+ if (e.origin !== location.origin) return
95
+ take(e.data, (a) => (e.source as Window | null)?.postMessage(a, location.origin))
96
+ }
97
+ addEventListener('message', onWindowMessage)
98
+ })
99
+
100
+ popup.location.href = authorizeUrl({
101
+ authorizationUrl: i.authorizationUrl,
102
+ clientId: i.clientId,
103
+ redirectUri: redirect,
104
+ scopes: i.scopes,
105
+ state,
106
+ challenge,
107
+ })
108
+
109
+ const back = await returned
110
+ const { code } = parseCallback(
111
+ {
112
+ state: back.state,
113
+ ...(back.code ? { code: back.code } : {}),
114
+ ...(back.error ? { error: back.error } : {}),
115
+ ...(back.description ? { error_description: back.description } : {}),
116
+ },
117
+ state,
118
+ )
119
+ const body = await i.exchange(
120
+ tokenRequestFields({ code, verifier, clientId: i.clientId, redirectUri: redirect }),
121
+ i.signal,
122
+ )
123
+ return parseTokenResponse(body)
124
+ } finally {
125
+ channel.close()
126
+ if (onWindowMessage) removeEventListener('message', onWindowMessage)
127
+ try {
128
+ popup.close()
129
+ } catch {
130
+ /* a severed popup cannot be closed from here; the callback page closes itself */
131
+ }
132
+ }
133
+ }
134
+
135
+ // ---- small per-browser conveniences: a client id is public, and an expiry note is not a secret ----
136
+
137
+ const CLIENTS = 'specbook.oauth.clients.v1'
138
+ const EXPIRY = 'specbook.oauth.expiry.v1'
139
+
140
+ const readJson = (store: Storage, key: string): Record<string, any> => {
141
+ try {
142
+ return JSON.parse(store.getItem(key) ?? '{}')
143
+ } catch {
144
+ return {}
145
+ }
146
+ }
147
+ const writeJson = (store: Storage, key: string, value: unknown) => {
148
+ try {
149
+ store.setItem(key, JSON.stringify(value))
150
+ } catch {
151
+ /* blocked or full: the page still works without it */
152
+ }
153
+ }
154
+
155
+ export const recallClientId = (authorizationUrl: string): string | undefined => {
156
+ try {
157
+ return readJson(localStorage, CLIENTS)[authorizationUrl]
158
+ } catch {
159
+ return undefined
160
+ }
161
+ }
162
+
163
+ export const rememberClientId = (authorizationUrl: string, clientId: string): void => {
164
+ try {
165
+ writeJson(localStorage, CLIENTS, {
166
+ ...readJson(localStorage, CLIENTS),
167
+ [authorizationUrl]: clientId,
168
+ })
169
+ } catch {
170
+ /* no storage */
171
+ }
172
+ }
173
+
174
+ const expiryKey = (origin: string, name: string) => `${origin}\n${name}`
175
+
176
+ /** Remembered for this tab only, to say "expires in 59 min"; the token itself stays in the secret store. */
177
+ export const noteExpiry = (origin: string, name: string, expiresIn: number | undefined): void => {
178
+ try {
179
+ const t = readJson(sessionStorage, EXPIRY)
180
+ if (expiresIn === undefined) delete t[expiryKey(origin, name)]
181
+ else t[expiryKey(origin, name)] = Date.now() + expiresIn * 1000
182
+ writeJson(sessionStorage, EXPIRY, t)
183
+ } catch {
184
+ /* no storage */
185
+ }
186
+ }
187
+
188
+ export const expiryOf = (origin: string, name: string): number | undefined => {
189
+ try {
190
+ return readJson(sessionStorage, EXPIRY)[expiryKey(origin, name)]
191
+ } catch {
192
+ return undefined
193
+ }
194
+ }
@@ -11,7 +11,7 @@ import {
11
11
  import type { ComponentChildren } from 'preact'
12
12
  import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'
13
13
  import { engine } from '../runtime'
14
- import { version } from '../store'
14
+ import { env, version } from '../store'
15
15
  import { type Draft, requestParts } from './draft'
16
16
 
17
17
  const LANG_KEY = 'specbook:code-lang'
@@ -54,14 +54,15 @@ export function CodeSample({ service, profile, secured, make, sent }: Props) {
54
54
  if (!live) return
55
55
  const svc = c.services?.[service]
56
56
  const urls = svc?.baseUrl ?? {}
57
- setBase(urls.live ?? Object.values(urls)[0])
57
+ // the environment being used, so a sample for the sandbox points at the sandbox
58
+ setBase(urls[env.value] ?? urls.live ?? Object.values(urls)[0])
58
59
  const name = profile === 'service' ? svc?.auth : profile
59
60
  setApplied(name ? (c.auth?.[name] as AuthProfile | undefined)?.apply : undefined)
60
61
  })
61
62
  return () => {
62
63
  live = false
63
64
  }
64
- }, [service, profile])
65
+ }, [service, profile, env.value])
65
66
 
66
67
  const built = useMemo<{ text?: string; error?: string }>(() => {
67
68
  try {