@specbook/vite 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.
package/README.md CHANGED
@@ -4,4 +4,4 @@ The Specbook plugin for Vite: the registry, the docs UI hooks and the dev server
4
4
 
5
5
  Part of [Specbook](https://github.com/specbook-dev/specbook), Storybook for APIs. See the repository for documentation.
6
6
 
7
- Source-available under the [Functional Source License (FSL-1.1-MIT)](LICENSE): free for internal and personal use, not for a competing commercial product, and each version becomes MIT two years after its release.
7
+ Source-available under the [Functional Source License (FSL-1.1-MIT)](LICENSE). Free to use, including at work and in commercial projects; the only restriction is offering it, or something substantially similar built from it, to others as a competing commercial product or service. Each version becomes MIT two years after its release.
package/dist/index.js CHANGED
@@ -5,78 +5,25 @@ import { createRequire } from "module";
5
5
  import { basename, dirname, join as join4, relative as relative4, resolve as resolve2, sep as sep4 } from "path";
6
6
  import fg4 from "fast-glob";
7
7
 
8
- // src/docs.ts
9
- import { readFile } from "fs/promises";
10
- import { relative, sep } from "path";
11
- import fg from "fast-glob";
12
- import { marked } from "marked";
13
- function frontmatter(src) {
14
- const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(src);
15
- if (!m) return { meta: {}, body: src };
16
- const meta = {};
17
- for (const line of m[1].split(/\r?\n/)) {
18
- const i = line.indexOf(":");
19
- if (i > 0)
20
- meta[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, "");
8
+ // ../core/src/oauth.ts
9
+ var OAuthError = class extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = "OAuthError";
21
13
  }
22
- return { meta, body: src.slice(m[0].length) };
23
- }
24
- var OP_FOLDER = /(?:^|\/)(?:requests|apis)\/([^/]+)\/([^/]+)\/(?:readme|index)\.md$/i;
25
- var OP_FLAT = /(?:^|\/)(?:requests|apis)\/([^/]+)\/docs\/([^/]+)\.md$/i;
26
- var NOT_AN_OPERATION = /^(readme|index)$/i;
27
- function colocated(file) {
28
- if (/\.seq\.md$/.test(file)) return { sequenceFile: file.replace(/\.md$/, ".ts") };
29
- if (/\.req\.md$/.test(file)) return { requestFile: file.replace(/\.md$/, ".ts") };
30
- const folder = OP_FOLDER.exec(file);
31
- if (folder && folder[2] !== "docs") return { operation: `${folder[1]}/${folder[2]}` };
32
- const flat = OP_FLAT.exec(file);
33
- if (flat && !NOT_AN_OPERATION.test(flat[2]))
34
- return { operation: `${flat[1]}/${flat[2]}` };
35
- return void 0;
36
- }
37
- async function scanDocs(root) {
38
- const files = await fg("**/*.md", {
39
- cwd: root,
40
- absolute: true,
41
- ignore: ["**/node_modules/**", "**/.generated/**", "**/dist/**", "**/.git/**"]
42
- });
43
- const pages = [];
44
- const claimed = /* @__PURE__ */ new Map();
45
- for (const abs of files.sort()) {
46
- const { meta, body } = frontmatter(await readFile(abs, "utf8"));
47
- const file = relative(root, abs).split(sep).join("/");
48
- const near = colocated(file);
49
- if (!near && !meta.operation && !meta.sequence && !meta.page) continue;
50
- const operation = meta.operation ?? near?.operation;
51
- if (operation) {
52
- const first = claimed.get(operation);
53
- if (first) {
54
- console.warn(
55
- `[specbook] ${file} documents ${operation}, which ${first} already does; ignored`
56
- );
57
- continue;
58
- }
59
- claimed.set(operation, file);
60
- }
61
- pages.push({
62
- file,
63
- operation,
64
- sequence: meta.sequence,
65
- sequenceFile: near?.sequenceFile,
66
- requestFile: near?.requestFile,
67
- replace: meta.replace === "true" || void 0,
68
- title: meta.title ?? meta.page,
69
- html: marked.parse(body, { async: false })
70
- });
71
- }
72
- return pages;
14
+ };
15
+ function parseDiscovery(json3) {
16
+ const d = json3 && typeof json3 === "object" ? json3 : {};
17
+ const url = (v) => typeof v === "string" && /^https?:\/\//i.test(v) ? v : void 0;
18
+ const authorizationUrl = url(d.authorization_endpoint);
19
+ const tokenUrl = url(d.token_endpoint);
20
+ if (!authorizationUrl || !tokenUrl)
21
+ throw new OAuthError("The provider\u2019s discovery document has no authorization or token endpoint");
22
+ const supported = Array.isArray(d.scopes_supported) ? d.scopes_supported.filter((x) => typeof x === "string") : [];
23
+ const names = ["openid", ...supported.filter((x) => x !== "openid")];
24
+ return { authorizationUrl, tokenUrl, scopes: Object.fromEntries(names.map((n) => [n, ""])) };
73
25
  }
74
26
 
75
- // src/environments.ts
76
- import { existsSync } from "fs";
77
- import { mkdir, readdir, readFile as readFile2, writeFile } from "fs/promises";
78
- import { join } from "path";
79
-
80
27
  // ../core/src/proxy/policy.ts
81
28
  var DEFAULT_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
82
29
  var DEFAULT_MAX_BODY = 25 * 1024 * 1024;
@@ -371,7 +318,119 @@ function parseDotenv(text) {
371
318
  return out;
372
319
  }
373
320
 
321
+ // src/discovery.ts
322
+ var FETCH_TIMEOUT_MS = 5e3;
323
+ function createDiscovery(opts = {}) {
324
+ const f = opts.fetch ?? fetch;
325
+ const now = opts.now ?? Date.now;
326
+ const retryAfter = opts.retryAfterMs ?? 3e4;
327
+ const found = /* @__PURE__ */ new Map();
328
+ const failedAt = /* @__PURE__ */ new Map();
329
+ const inFlight = /* @__PURE__ */ new Map();
330
+ async function look(url) {
331
+ try {
332
+ const res = await f(url, {
333
+ headers: { accept: "application/json" },
334
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
335
+ });
336
+ if (!res.ok) throw new Error(`answered ${res.status}`);
337
+ const origin = new URL(parseDiscovery(await res.json()).tokenUrl).origin;
338
+ found.set(url, origin);
339
+ failedAt.delete(url);
340
+ return origin;
341
+ } catch (e) {
342
+ failedAt.set(url, now());
343
+ opts.warn?.(
344
+ `Could not read the OpenID Connect discovery document at ${url} (${e instanceof Error ? e.message : String(e)}). Sign in is unavailable until it can; pasting a token still works.`
345
+ );
346
+ return void 0;
347
+ }
348
+ }
349
+ return function tokenOrigin(url) {
350
+ const known = found.get(url);
351
+ if (known) return Promise.resolve(known);
352
+ const failed = failedAt.get(url);
353
+ if (failed !== void 0 && now() - failed < retryAfter) return Promise.resolve(void 0);
354
+ let pending = inFlight.get(url);
355
+ if (!pending) {
356
+ pending = look(url).finally(() => inFlight.delete(url));
357
+ inFlight.set(url, pending);
358
+ }
359
+ return pending;
360
+ };
361
+ }
362
+
363
+ // src/docs.ts
364
+ import { readFile } from "fs/promises";
365
+ import { relative, sep } from "path";
366
+ import fg from "fast-glob";
367
+ import { marked } from "marked";
368
+ function frontmatter(src) {
369
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(src);
370
+ if (!m) return { meta: {}, body: src };
371
+ const meta = {};
372
+ for (const line of m[1].split(/\r?\n/)) {
373
+ const i = line.indexOf(":");
374
+ if (i > 0)
375
+ meta[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, "");
376
+ }
377
+ return { meta, body: src.slice(m[0].length) };
378
+ }
379
+ var OP_FOLDER = /(?:^|\/)(?:requests|apis)\/([^/]+)\/([^/]+)\/(?:readme|index)\.md$/i;
380
+ var OP_FLAT = /(?:^|\/)(?:requests|apis)\/([^/]+)\/docs\/([^/]+)\.md$/i;
381
+ var NOT_AN_OPERATION = /^(readme|index)$/i;
382
+ function colocated(file) {
383
+ if (/\.seq\.md$/.test(file)) return { sequenceFile: file.replace(/\.md$/, ".ts") };
384
+ if (/\.req\.md$/.test(file)) return { requestFile: file.replace(/\.md$/, ".ts") };
385
+ const folder = OP_FOLDER.exec(file);
386
+ if (folder && folder[2] !== "docs") return { operation: `${folder[1]}/${folder[2]}` };
387
+ const flat = OP_FLAT.exec(file);
388
+ if (flat && !NOT_AN_OPERATION.test(flat[2]))
389
+ return { operation: `${flat[1]}/${flat[2]}` };
390
+ return void 0;
391
+ }
392
+ async function scanDocs(root) {
393
+ const files = await fg("**/*.md", {
394
+ cwd: root,
395
+ absolute: true,
396
+ ignore: ["**/node_modules/**", "**/.generated/**", "**/dist/**", "**/.git/**"]
397
+ });
398
+ const pages = [];
399
+ const claimed = /* @__PURE__ */ new Map();
400
+ for (const abs of files.sort()) {
401
+ const { meta, body } = frontmatter(await readFile(abs, "utf8"));
402
+ const file = relative(root, abs).split(sep).join("/");
403
+ const near = colocated(file);
404
+ if (!near && !meta.operation && !meta.sequence && !meta.page) continue;
405
+ const operation = meta.operation ?? near?.operation;
406
+ if (operation) {
407
+ const first = claimed.get(operation);
408
+ if (first) {
409
+ console.warn(
410
+ `[specbook] ${file} documents ${operation}, which ${first} already does; ignored`
411
+ );
412
+ continue;
413
+ }
414
+ claimed.set(operation, file);
415
+ }
416
+ pages.push({
417
+ file,
418
+ operation,
419
+ sequence: meta.sequence,
420
+ sequenceFile: near?.sequenceFile,
421
+ requestFile: near?.requestFile,
422
+ replace: meta.replace === "true" || void 0,
423
+ title: meta.title ?? meta.page,
424
+ html: marked.parse(body, { async: false })
425
+ });
426
+ }
427
+ return pages;
428
+ }
429
+
374
430
  // src/environments.ts
431
+ import { existsSync } from "fs";
432
+ import { mkdir, readdir, readFile as readFile2, writeFile } from "fs/promises";
433
+ import { join } from "path";
375
434
  var ENV_NAME = /^[A-Za-z0-9_-]+$/;
376
435
  var VAR_NAME = /^[A-Za-z_][\w.-]*$/;
377
436
  var environmentsDir = (root) => join(root, ".specbook", "environments");
@@ -541,6 +600,15 @@ function requestsDirFor(root) {
541
600
  }
542
601
 
543
602
  // src/proxy.ts
603
+ var AUTH_SERVICE_PREFIX = "auth:";
604
+ var DISCOVERY_SERVICE_PREFIX = "discovery:";
605
+ var decode = (s) => {
606
+ try {
607
+ return decodeURIComponent(s);
608
+ } catch {
609
+ return s;
610
+ }
611
+ };
544
612
  var MOUNT = "/__specbook/proxy";
545
613
  function readBody(req) {
546
614
  return new Promise((res, rej) => {
@@ -568,7 +636,13 @@ async function handleProxy2(req, res, deps) {
568
636
  const response = await handleProxy(request, {
569
637
  mount: MOUNT,
570
638
  policy: {
571
- upstream: (env, service) => config.services[service]?.baseUrl[env],
639
+ upstream: (env, service) => {
640
+ if (service.startsWith(AUTH_SERVICE_PREFIX))
641
+ return config.tokenOrigins?.[decode(service.slice(AUTH_SERVICE_PREFIX.length))];
642
+ if (service.startsWith(DISCOVERY_SERVICE_PREFIX))
643
+ return config.discoveryOrigins?.[decode(service.slice(DISCOVERY_SERVICE_PREFIX.length))];
644
+ return config.services[service]?.baseUrl[env];
645
+ },
572
646
  // a developer's own machine: their local services are the point
573
647
  allowPrivateHosts: true
574
648
  },
@@ -805,6 +879,7 @@ function specbook(options) {
805
879
  let notifyRegistryChanged = () => {
806
880
  };
807
881
  const memorySecrets = {};
882
+ const tokenOriginOf = createDiscovery({ warn: (m) => server?.config.logger.warn(m) });
808
883
  async function registryCode() {
809
884
  const files = await scanProject(root);
810
885
  const manifests = {};
@@ -1088,11 +1163,27 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1088
1163
  if (!server || !cfgFile) return { services: {}, secretNames: {} };
1089
1164
  const mod = await server.ssrLoadModule(cfgFile);
1090
1165
  const cfg = mod.default ?? {};
1166
+ const tokenOrigins = {};
1167
+ const discoveryOrigins = {};
1168
+ for (const [name, profile] of Object.entries(cfg.auth ?? {})) {
1169
+ try {
1170
+ const { tokenUrl, openIdConnectUrl } = profile.oauth ?? {};
1171
+ if (tokenUrl) tokenOrigins[name] = new URL(tokenUrl).origin;
1172
+ else if (openIdConnectUrl) {
1173
+ discoveryOrigins[name] = new URL(openIdConnectUrl).origin;
1174
+ const token = await tokenOriginOf(openIdConnectUrl);
1175
+ if (token) tokenOrigins[name] = token;
1176
+ }
1177
+ } catch {
1178
+ }
1179
+ }
1091
1180
  return {
1092
1181
  services: Object.fromEntries(
1093
1182
  Object.entries(cfg.services ?? {}).map(([k, v]) => [k, { baseUrl: v.baseUrl ?? {} }])
1094
1183
  ),
1095
- secretNames: cfg.secrets ?? {}
1184
+ secretNames: cfg.secrets ?? {},
1185
+ tokenOrigins,
1186
+ discoveryOrigins
1096
1187
  };
1097
1188
  }
1098
1189
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specbook/vite",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The Specbook plugin for Vite: the registry, the docs UI hooks and the dev server credential proxy.",
5
5
  "keywords": [
6
6
  "specbook",
@@ -34,8 +34,8 @@
34
34
  }
35
35
  },
36
36
  "dependencies": {
37
- "@specbook/codegen": "0.1.0",
38
- "@specbook/core": "0.1.0",
37
+ "@specbook/codegen": "0.2.0",
38
+ "@specbook/core": "0.2.0",
39
39
  "fast-glob": "3.3.2",
40
40
  "marked": "^18.1.1"
41
41
  },
@@ -0,0 +1,60 @@
1
+ import { parseDiscovery } from '@specbook/core'
2
+
3
+ const FETCH_TIMEOUT_MS = 5_000
4
+
5
+ /**
6
+ * Learns, on the server, where an OpenID Connect provider's token endpoint is. The page needs the proxy to
7
+ * forward its code exchange there, and the proxy only forwards to origins it was told about by the
8
+ * project's own config; a discovery document is the config's pointer to that origin. Successes are kept
9
+ * for the life of the dev server; a failure is not retried for a short while, so a provider that is down
10
+ * costs one slow request, not one per call.
11
+ */
12
+ export function createDiscovery(
13
+ opts: {
14
+ fetch?: typeof fetch
15
+ retryAfterMs?: number
16
+ now?: () => number
17
+ warn?: (message: string) => void
18
+ } = {},
19
+ ) {
20
+ const f = opts.fetch ?? fetch
21
+ const now = opts.now ?? Date.now
22
+ const retryAfter = opts.retryAfterMs ?? 30_000
23
+ const found = new Map<string, string>()
24
+ const failedAt = new Map<string, number>()
25
+ const inFlight = new Map<string, Promise<string | undefined>>()
26
+
27
+ async function look(url: string): Promise<string | undefined> {
28
+ try {
29
+ const res = await f(url, {
30
+ headers: { accept: 'application/json' },
31
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
32
+ })
33
+ if (!res.ok) throw new Error(`answered ${res.status}`)
34
+ const origin = new URL(parseDiscovery(await res.json()).tokenUrl).origin
35
+ found.set(url, origin)
36
+ failedAt.delete(url)
37
+ return origin
38
+ } catch (e) {
39
+ failedAt.set(url, now())
40
+ opts.warn?.(
41
+ `Could not read the OpenID Connect discovery document at ${url} (${e instanceof Error ? e.message : String(e)}). Sign in is unavailable until it can; pasting a token still works.`,
42
+ )
43
+ return undefined
44
+ }
45
+ }
46
+
47
+ /** The origin of the provider's token endpoint, or `undefined` when discovery has failed. */
48
+ return function tokenOrigin(url: string): Promise<string | undefined> {
49
+ const known = found.get(url)
50
+ if (known) return Promise.resolve(known)
51
+ const failed = failedAt.get(url)
52
+ if (failed !== undefined && now() - failed < retryAfter) return Promise.resolve(undefined)
53
+ let pending = inFlight.get(url)
54
+ if (!pending) {
55
+ pending = look(url).finally(() => inFlight.delete(url))
56
+ inFlight.set(url, pending)
57
+ }
58
+ return pending
59
+ }
60
+ }
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import { createRequire } from 'node:module'
5
5
  import { basename, dirname, join, relative, resolve, sep } from 'node:path'
6
6
  import fg from 'fast-glob'
7
7
  import type { Plugin, ViteDevServer } from 'vite'
8
+ import { createDiscovery } from './discovery'
8
9
  import { scanDocs } from './docs'
9
10
  import {
10
11
  ensureEnvironmentsDir,
@@ -156,6 +157,8 @@ export function specbook(options: SpecbookPluginOptions): Plugin {
156
157
  let notifyRegistryChanged: () => void = () => {}
157
158
  /** Credentials entered in the UI: never written to disk, gone when the server stops. */
158
159
  const memorySecrets: Record<string, Record<string, string>> = {}
160
+ // where each OpenID Connect profile's token endpoint is, read once from its discovery document
161
+ const tokenOriginOf = createDiscovery({ warn: (m) => server?.config.logger.warn(m) })
159
162
 
160
163
  async function registryCode(): Promise<string> {
161
164
  const files = await scanProject(root)
@@ -438,12 +441,30 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
438
441
  const cfg = (mod.default ?? {}) as {
439
442
  services?: Record<string, { baseUrl?: Record<string, string> }>
440
443
  secrets?: Record<string, { env?: string }>
444
+ auth?: Record<string, { oauth?: { tokenUrl?: string; openIdConnectUrl?: string } }>
445
+ }
446
+ const tokenOrigins: Record<string, string> = {}
447
+ const discoveryOrigins: Record<string, string> = {}
448
+ for (const [name, profile] of Object.entries(cfg.auth ?? {})) {
449
+ try {
450
+ const { tokenUrl, openIdConnectUrl } = profile.oauth ?? {}
451
+ if (tokenUrl) tokenOrigins[name] = new URL(tokenUrl).origin
452
+ else if (openIdConnectUrl) {
453
+ discoveryOrigins[name] = new URL(openIdConnectUrl).origin
454
+ const token = await tokenOriginOf(openIdConnectUrl)
455
+ if (token) tokenOrigins[name] = token
456
+ }
457
+ } catch {
458
+ /* an unparsable URL is left out; a sign-in against it fails with a 404 */
459
+ }
441
460
  }
442
461
  return {
443
462
  services: Object.fromEntries(
444
463
  Object.entries(cfg.services ?? {}).map(([k, v]) => [k, { baseUrl: v.baseUrl ?? {} }]),
445
464
  ),
446
465
  secretNames: cfg.secrets ?? {},
466
+ tokenOrigins,
467
+ discoveryOrigins,
447
468
  }
448
469
  }
449
470
 
package/src/proxy.ts CHANGED
@@ -4,6 +4,29 @@ import { handleProxy as proxy } from '@specbook/core'
4
4
  export interface ProxyConfig {
5
5
  services: Record<string, { baseUrl: Record<string, string | undefined> }>
6
6
  secretNames: Record<string, { env?: string }>
7
+ /**
8
+ * Origin of each OAuth profile's token endpoint, read from the project's own config. A sign-in
9
+ * exchanges its code through `<mount>/<env>/auth:<profile>/…`; the page chooses the path, never the host.
10
+ */
11
+ tokenOrigins?: Record<string, string>
12
+ /**
13
+ * Origin of each OpenID Connect profile's discovery document, addressed as
14
+ * `<mount>/<env>/discovery:<profile>/…` (the token endpoint may be on another host).
15
+ */
16
+ discoveryOrigins?: Record<string, string>
17
+ }
18
+
19
+ /** The reserved service name a sign-in's token exchange is addressed to. */
20
+ export const AUTH_SERVICE_PREFIX = 'auth:'
21
+ /** The reserved service name a sign-in's discovery document is fetched through. */
22
+ export const DISCOVERY_SERVICE_PREFIX = 'discovery:'
23
+
24
+ const decode = (s: string) => {
25
+ try {
26
+ return decodeURIComponent(s)
27
+ } catch {
28
+ return s
29
+ }
7
30
  }
8
31
 
9
32
  export interface ProxyDeps {
@@ -61,7 +84,13 @@ export async function handleProxy(
61
84
  const response = await proxy(request, {
62
85
  mount: MOUNT,
63
86
  policy: {
64
- upstream: (env, service) => config.services[service]?.baseUrl[env],
87
+ upstream: (env, service) => {
88
+ if (service.startsWith(AUTH_SERVICE_PREFIX))
89
+ return config.tokenOrigins?.[decode(service.slice(AUTH_SERVICE_PREFIX.length))]
90
+ if (service.startsWith(DISCOVERY_SERVICE_PREFIX))
91
+ return config.discoveryOrigins?.[decode(service.slice(DISCOVERY_SERVICE_PREFIX.length))]
92
+ return config.services[service]?.baseUrl[env]
93
+ },
65
94
  // a developer's own machine: their local services are the point
66
95
  allowPrivateHosts: true,
67
96
  },