@docsector/docsector-reader 1.2.1 β†’ 1.2.3

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
@@ -23,6 +23,7 @@ Transform Markdown content into beautiful, navigable documentation sites β€” wit
23
23
  - πŸ“‹ **Copy Page** β€” One-click button copies the current page as raw Markdown, ready to paste into LLMs
24
24
  - πŸ“„ **View as Markdown** β€” Open any page as plain text by appending `.md` to the URL, with locale support (`?lang=`)
25
25
  - 🧠 **Markdown Negotiation** β€” Requests with `Accept: text/markdown` receive markdown responses, while browsers keep HTML by default
26
+ - πŸ” **Web Bot Auth Directory** β€” Optional signed JWKS directory at `/.well-known/http-message-signatures-directory` for bot identity verification
26
27
  - πŸ€– **Open in ChatGPT / Claude** β€” One-click links to open the current page directly in ChatGPT or Claude for Q&A
27
28
  - πŸ€– **LLM Bot Detection** β€” Automatically serves raw Markdown to known AI crawlers (GPTBot, ClaudeBot, PerplexityBot, GrokBot, and others)
28
29
  - πŸ—ΊοΈ **Sitemap Generation** β€” Automatic `sitemap.xml` generation at build time with all page URLs (requires `siteUrl` in config)
@@ -48,6 +49,7 @@ Transform Markdown content into beautiful, navigable documentation sites β€” wit
48
49
  - πŸ“… **Last Updated Date** β€” Automatic per-page "last updated" date from git commit history, locale-formatted
49
50
  - πŸ“Š **Translation Progress** β€” Automatic translation percentage based on header coverage
50
51
  - 🧠 **Markdown Negotiation** β€” Responds with Markdown when clients send `Accept: text/markdown`, while keeping HTML as browser default
52
+ - πŸ” **Web Bot Auth** β€” Can publish a signed HTTP message signatures directory and includes helpers to sign outbound bot requests
51
53
  - 🏠 **Markdown Home at Root** β€” Homepage is rendered from `src/pages/Homepage.{lang}.md` directly at `/`
52
54
  - 🧭 **Quick Links Custom Element** β€” Use `<d-quick-links>` and `<d-quick-link>` in Markdown to render rich home navigation cards
53
55
  - πŸ—‚οΈ **API Catalog Well-Known** β€” Auto-generates `/.well-known/api-catalog` as Linkset JSON for machine-readable API discovery
@@ -202,6 +204,62 @@ export default {
202
204
 
203
205
  Set any target to `null` or `false` to disable that relation.
204
206
 
207
+ ---
208
+
209
+ ## πŸ” Web Bot Auth
210
+
211
+ Docsector Reader can publish a signed Web Bot Auth directory at:
212
+
213
+ - `/.well-known/http-message-signatures-directory`
214
+
215
+ This response is served by Cloudflare Pages runtime middleware and includes:
216
+
217
+ - `Content-Type: application/http-message-signatures-directory+json`
218
+ - `Signature`
219
+ - `Signature-Input`
220
+
221
+ ### Configure directory publishing
222
+
223
+ ```javascript
224
+ export default {
225
+ // ...other config
226
+
227
+ webBotAuth: {
228
+ enabled: true,
229
+ directoryPath: '/.well-known/http-message-signatures-directory',
230
+ jwksEnv: 'WEB_BOT_AUTH_JWKS',
231
+ privateJwkEnv: 'WEB_BOT_AUTH_PRIVATE_JWK',
232
+ keyIdEnv: 'WEB_BOT_AUTH_KEY_ID',
233
+ keyId: null,
234
+ signatureMaxAge: 300,
235
+ signatureLabel: 'sig1'
236
+ }
237
+ }
238
+ ```
239
+
240
+ Required runtime variables (Cloudflare Pages / Workers environment):
241
+
242
+ - `WEB_BOT_AUTH_JWKS`: JSON string with a valid JWKS payload (`{ "keys": [...] }`)
243
+ - `WEB_BOT_AUTH_PRIVATE_JWK`: JSON string for an Ed25519 private JWK used to sign directory responses
244
+ - `WEB_BOT_AUTH_KEY_ID`: optional key id override (thumbprint or `kid`)
245
+
246
+ ### Sign outbound bot requests
247
+
248
+ Use the helper export:
249
+
250
+ ```javascript
251
+ import { createWebBotAuthHeaders } from '@docsector/docsector-reader/web-bot-auth'
252
+
253
+ const signed = await createWebBotAuthHeaders({
254
+ url: 'https://crawltest.com/cdn-cgi/web-bot-auth',
255
+ privateJwk,
256
+ keyId: 'your-jwk-thumbprint',
257
+ signatureAgent: 'https://docs.example.com/.well-known/http-message-signatures-directory'
258
+ })
259
+ ```
260
+
261
+ Attach returned headers to your outbound request (`Signature-Agent`, `Signature-Input`, `Signature`).
262
+
205
263
  ### Validate
206
264
 
207
265
  ```bash
package/bin/docsector.js CHANGED
@@ -23,7 +23,7 @@ const packageRoot = resolve(__dirname, '..')
23
23
  const args = process.argv.slice(2)
24
24
  const command = args[0]
25
25
 
26
- const VERSION = '1.2.1'
26
+ const VERSION = '1.2.3'
27
27
 
28
28
  const HELP = `
29
29
  Docsector Reader v${VERSION}
@@ -155,6 +155,21 @@ export default {
155
155
  // agentFallback: true
156
156
  // },
157
157
 
158
+ // @ Web Bot Auth (optional)
159
+ // Publishes a signed JWKS directory at
160
+ // /.well-known/http-message-signatures-directory
161
+ // using runtime environment variables.
162
+ // webBotAuth: {
163
+ // enabled: true,
164
+ // directoryPath: '/.well-known/http-message-signatures-directory',
165
+ // jwksEnv: 'WEB_BOT_AUTH_JWKS',
166
+ // privateJwkEnv: 'WEB_BOT_AUTH_PRIVATE_JWK',
167
+ // keyIdEnv: 'WEB_BOT_AUTH_KEY_ID',
168
+ // keyId: null,
169
+ // signatureMaxAge: 300,
170
+ // signatureLabel: 'sig1'
171
+ // },
172
+
158
173
  // @ Languages
159
174
  languages: [
160
175
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docsector/docsector-reader",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "A documentation rendering engine built with Vue 3, Quasar v2 and Vite. Transform Markdown into beautiful, navigable documentation sites.",
5
5
  "productName": "Docsector Reader",
6
6
  "author": "Rodrigo de Araujo Vieira",
@@ -13,6 +13,7 @@
13
13
  ".": "./src/index.js",
14
14
  "./config": "./docsector.config.js",
15
15
  "./quasar-factory": "./src/quasar.factory.js",
16
+ "./web-bot-auth": "./src/web-bot-auth/index.js",
16
17
  "./i18n": "./src/i18n/helpers.js",
17
18
  "./boot/store": "./src/boot/store.js",
18
19
  "./boot/QZoom": "./src/boot/QZoom.js",
package/src/index.js CHANGED
@@ -57,6 +57,15 @@
57
57
  * @param {Object} [config.markdownNegotiation] - Markdown content negotiation settings for agents
58
58
  * @param {boolean} [config.markdownNegotiation.enabled=true] - Enables markdown negotiation by Accept header in production runtime
59
59
  * @param {boolean} [config.markdownNegotiation.agentFallback=true] - Enables markdown fallback for known AI bot user agents when Accept is absent
60
+ * @param {Object} [config.webBotAuth] - Web Bot Auth settings for signed bot identity
61
+ * @param {boolean} [config.webBotAuth.enabled=false] - Enables Web Bot Auth directory publishing and response signature headers
62
+ * @param {string} [config.webBotAuth.directoryPath='/.well-known/http-message-signatures-directory'] - Well-known URI where JWKS directory is exposed
63
+ * @param {string} [config.webBotAuth.jwksEnv='WEB_BOT_AUTH_JWKS'] - Environment variable containing JWKS JSON
64
+ * @param {string} [config.webBotAuth.privateJwkEnv='WEB_BOT_AUTH_PRIVATE_JWK'] - Environment variable containing private Ed25519 JWK JSON used to sign the directory response
65
+ * @param {string} [config.webBotAuth.keyIdEnv='WEB_BOT_AUTH_KEY_ID'] - Environment variable containing signature key identifier (thumbprint or kid)
66
+ * @param {string|null} [config.webBotAuth.keyId=null] - Optional static fallback key identifier when env var is absent
67
+ * @param {number} [config.webBotAuth.signatureMaxAge=300] - Signature validity window in seconds for directory responses
68
+ * @param {string} [config.webBotAuth.signatureLabel='sig1'] - Signature label used in Signature and Signature-Input headers
60
69
  * @returns {Object} Resolved Docsector configuration
61
70
  */
62
71
  export function createDocsector (config = {}) {
@@ -118,6 +127,18 @@ export function createDocsector (config = {}) {
118
127
  enabled: true,
119
128
  agentFallback: true,
120
129
  ...config.markdownNegotiation
130
+ },
131
+
132
+ webBotAuth: {
133
+ enabled: false,
134
+ directoryPath: '/.well-known/http-message-signatures-directory',
135
+ jwksEnv: 'WEB_BOT_AUTH_JWKS',
136
+ privateJwkEnv: 'WEB_BOT_AUTH_PRIVATE_JWK',
137
+ keyIdEnv: 'WEB_BOT_AUTH_KEY_ID',
138
+ keyId: null,
139
+ signatureMaxAge: 300,
140
+ signatureLabel: 'sig1',
141
+ ...config.webBotAuth
121
142
  }
122
143
  }
123
144
  }
@@ -742,15 +742,172 @@ function createMarkdownBuildPlugin (projectRoot) {
742
742
  const markdownNegotiationConfig = config.markdownNegotiation || {}
743
743
  const markdownNegotiationEnabled = markdownNegotiationConfig.enabled !== false
744
744
  const markdownAgentFallback = markdownNegotiationConfig.agentFallback !== false
745
-
746
- if (markdownNegotiationEnabled) {
745
+ const webBotAuthConfig = config.webBotAuth || {}
746
+ const webBotAuthEnabled = webBotAuthConfig.enabled === true
747
+ const webBotAuthDirectoryPath = webBotAuthConfig.directoryPath || '/.well-known/http-message-signatures-directory'
748
+ const webBotAuthJwksEnv = webBotAuthConfig.jwksEnv || 'WEB_BOT_AUTH_JWKS'
749
+ const webBotAuthPrivateJwkEnv = webBotAuthConfig.privateJwkEnv || 'WEB_BOT_AUTH_PRIVATE_JWK'
750
+ const webBotAuthKeyIdEnv = webBotAuthConfig.keyIdEnv || 'WEB_BOT_AUTH_KEY_ID'
751
+ const webBotAuthStaticKeyId = webBotAuthConfig.keyId || null
752
+ const webBotAuthSignatureMaxAge = Number.isFinite(webBotAuthConfig.signatureMaxAge)
753
+ ? Math.max(30, Number(webBotAuthConfig.signatureMaxAge))
754
+ : 300
755
+ const webBotAuthSignatureLabel = webBotAuthConfig.signatureLabel || 'sig1'
756
+
757
+ if (markdownNegotiationEnabled || webBotAuthEnabled) {
747
758
  const functionsDir = resolve(projectRoot, 'functions')
748
759
  mkdirSync(functionsDir, { recursive: true })
749
760
 
750
761
  const middlewareCode = `const LLM_BOT_PATTERN = /GPTBot|ChatGPT-User|OAI-SearchBot|ClaudeBot|Claude-User|Claude-SearchBot|anthropic-ai|Google-Extended|Gemini-Deep-Research|PerplexityBot|Perplexity-User|Bytespider|CCBot|Meta-ExternalAgent|FacebookBot|Amazonbot|Applebot-Extended|cohere-ai|DuckAssistBot|GrokBot|AI2Bot|YouBot|PetalBot/i
751
762
 
752
763
  const DEFAULT_LANG = ${JSON.stringify(defaultLang)}
764
+ const MARKDOWN_ENABLED = ${markdownNegotiationEnabled ? 'true' : 'false'}
753
765
  const AGENT_FALLBACK = ${markdownAgentFallback ? 'true' : 'false'}
766
+ const WEB_BOT_AUTH_ENABLED = ${webBotAuthEnabled ? 'true' : 'false'}
767
+ const WEB_BOT_AUTH_DIRECTORY_PATH = ${JSON.stringify(webBotAuthDirectoryPath)}
768
+ const WEB_BOT_AUTH_JWKS_ENV = ${JSON.stringify(webBotAuthJwksEnv)}
769
+ const WEB_BOT_AUTH_PRIVATE_JWK_ENV = ${JSON.stringify(webBotAuthPrivateJwkEnv)}
770
+ const WEB_BOT_AUTH_KEY_ID_ENV = ${JSON.stringify(webBotAuthKeyIdEnv)}
771
+ const WEB_BOT_AUTH_STATIC_KEY_ID = ${JSON.stringify(webBotAuthStaticKeyId)}
772
+ const WEB_BOT_AUTH_SIGNATURE_MAX_AGE = ${webBotAuthSignatureMaxAge}
773
+ const WEB_BOT_AUTH_SIGNATURE_LABEL = ${JSON.stringify(webBotAuthSignatureLabel)}
774
+
775
+ const textEncoder = new TextEncoder()
776
+
777
+ function bytesToBase64 (bytes) {
778
+ let binary = ''
779
+ for (let i = 0; i < bytes.length; i++) {
780
+ binary += String.fromCharCode(bytes[i])
781
+ }
782
+ return btoa(binary)
783
+ }
784
+
785
+ function bytesToBase64Url (bytes) {
786
+ return bytesToBase64(bytes).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/g, '')
787
+ }
788
+
789
+ async function sha256 (input) {
790
+ const digest = await crypto.subtle.digest('SHA-256', textEncoder.encode(input))
791
+ return new Uint8Array(digest)
792
+ }
793
+
794
+ async function computeJwkThumbprint (jwk) {
795
+ if (!jwk || jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519' || !jwk.x) {
796
+ return null
797
+ }
798
+
799
+ const canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x })
800
+ const digest = await sha256(canonical)
801
+ return bytesToBase64Url(digest)
802
+ }
803
+
804
+ function parseJsonEnv (env, key) {
805
+ const raw = env?.[key]
806
+ if (!raw || typeof raw !== 'string') {
807
+ return null
808
+ }
809
+
810
+ try {
811
+ return JSON.parse(raw)
812
+ } catch {
813
+ return null
814
+ }
815
+ }
816
+
817
+ function badWebBotAuthResponse (message, status = 500) {
818
+ return new Response(JSON.stringify({ error: message }), {
819
+ status,
820
+ headers: {
821
+ 'Content-Type': 'application/json; charset=utf-8',
822
+ 'Cache-Control': 'no-store'
823
+ }
824
+ })
825
+ }
826
+
827
+ async function importEd25519PrivateKey (privateJwk) {
828
+ if (!privateJwk || privateJwk.kty !== 'OKP' || privateJwk.crv !== 'Ed25519' || !privateJwk.d || !privateJwk.x) {
829
+ return null
830
+ }
831
+
832
+ return crypto.subtle.importKey(
833
+ 'jwk',
834
+ privateJwk,
835
+ { name: 'Ed25519' },
836
+ false,
837
+ ['sign']
838
+ )
839
+ }
840
+
841
+ async function signDirectoryResponse ({ authority, keyId, created, expires, privateKey }) {
842
+ const params = '("@authority";req);created=' + created + ';expires=' + expires + ';keyid="' + keyId + '";alg="ed25519";tag="http-message-signatures-directory"'
843
+ const signatureInput = WEB_BOT_AUTH_SIGNATURE_LABEL + '=' + params
844
+ const base = '"@authority";req: ' + authority + '\\n"@signature-params": ' + params
845
+ const signature = await crypto.subtle.sign('Ed25519', privateKey, textEncoder.encode(base))
846
+
847
+ return {
848
+ signatureInput,
849
+ signature: WEB_BOT_AUTH_SIGNATURE_LABEL + '=:' + bytesToBase64(new Uint8Array(signature)) + ':'
850
+ }
851
+ }
852
+
853
+ async function handleWebBotAuthDirectory (request, env, pathname) {
854
+ if (!WEB_BOT_AUTH_ENABLED || pathname !== WEB_BOT_AUTH_DIRECTORY_PATH) {
855
+ return null
856
+ }
857
+
858
+ const jwks = parseJsonEnv(env, WEB_BOT_AUTH_JWKS_ENV)
859
+ if (!jwks || !Array.isArray(jwks.keys) || jwks.keys.length === 0) {
860
+ return badWebBotAuthResponse('Missing or invalid JWKS in env var ' + WEB_BOT_AUTH_JWKS_ENV)
861
+ }
862
+
863
+ const privateJwk = parseJsonEnv(env, WEB_BOT_AUTH_PRIVATE_JWK_ENV)
864
+ if (!privateJwk) {
865
+ return badWebBotAuthResponse('Missing or invalid private JWK in env var ' + WEB_BOT_AUTH_PRIVATE_JWK_ENV)
866
+ }
867
+
868
+ const privateKey = await importEd25519PrivateKey(privateJwk)
869
+ if (!privateKey) {
870
+ return badWebBotAuthResponse('Private JWK must be an Ed25519 OKP key with d and x')
871
+ }
872
+
873
+ const selectedPublicJwk = jwks.keys.find((key) => key && key.kty === 'OKP' && key.crv === 'Ed25519' && typeof key.x === 'string')
874
+ if (!selectedPublicJwk) {
875
+ return badWebBotAuthResponse('JWKS must include at least one Ed25519 public JWK')
876
+ }
877
+
878
+ const envKeyId = env?.[WEB_BOT_AUTH_KEY_ID_ENV]
879
+ const computedKeyId = await computeJwkThumbprint(selectedPublicJwk)
880
+ const keyId = envKeyId || WEB_BOT_AUTH_STATIC_KEY_ID || selectedPublicJwk.kid || computedKeyId
881
+ if (!keyId) {
882
+ return badWebBotAuthResponse('Unable to resolve keyid for directory signature')
883
+ }
884
+
885
+ const created = Math.floor(Date.now() / 1000)
886
+ const expires = created + WEB_BOT_AUTH_SIGNATURE_MAX_AGE
887
+ const authority = new URL(request.url).host
888
+
889
+ const signedHeaders = await signDirectoryResponse({
890
+ authority,
891
+ keyId,
892
+ created,
893
+ expires,
894
+ privateKey
895
+ })
896
+
897
+ const body = JSON.stringify(jwks, null, 2) + '\n'
898
+ const headers = new Headers({
899
+ 'Content-Type': 'application/http-message-signatures-directory+json',
900
+ 'Cache-Control': 'public, max-age=60',
901
+ Signature: signedHeaders.signature,
902
+ 'Signature-Input': signedHeaders.signatureInput
903
+ })
904
+
905
+ if (request.method === 'HEAD') {
906
+ return new Response(null, { status: 200, headers })
907
+ }
908
+
909
+ return new Response(body, { status: 200, headers })
910
+ }
754
911
 
755
912
  function wantsMarkdown (request) {
756
913
  const accept = (request.headers.get('accept') || '').toLowerCase()
@@ -792,6 +949,15 @@ export async function onRequest (context) {
792
949
  }
793
950
 
794
951
  const url = new URL(request.url)
952
+ const webBotAuthResponse = await handleWebBotAuthDirectory(request, env, url.pathname)
953
+ if (webBotAuthResponse) {
954
+ return webBotAuthResponse
955
+ }
956
+
957
+ if (!MARKDOWN_ENABLED) {
958
+ return next()
959
+ }
960
+
795
961
  if (shouldBypass(url.pathname)) {
796
962
  return next()
797
963
  }
@@ -833,7 +999,7 @@ export async function onRequest (context) {
833
999
  `
834
1000
 
835
1001
  writeFileSync(resolve(functionsDir, '_middleware.js'), middlewareCode)
836
- console.log(`\x1b[36m[docsector]\x1b[0m Generated markdown negotiation middleware at functions/_middleware.js`)
1002
+ console.log(`\x1b[36m[docsector]\x1b[0m Generated runtime middleware at functions/_middleware.js`)
837
1003
  }
838
1004
  console.log(`\x1b[36m[docsector]\x1b[0m Added _headers rule for .md files`)
839
1005
 
@@ -1049,7 +1215,7 @@ export async function onRequest (context) {
1049
1215
  }
1050
1216
 
1051
1217
  // Generate or merge _routes.json for Cloudflare Pages functions
1052
- if (config.mcp || markdownNegotiationEnabled) {
1218
+ if (config.mcp || markdownNegotiationEnabled || webBotAuthEnabled) {
1053
1219
  const routesPath = resolve(distDir, '_routes.json')
1054
1220
  let routes = { version: 1, include: [], exclude: [] }
1055
1221
  if (existsSync(routesPath)) {
@@ -1068,10 +1234,14 @@ export async function onRequest (context) {
1068
1234
  routes.include.push('/mcp')
1069
1235
  }
1070
1236
 
1237
+ if (webBotAuthEnabled && !markdownNegotiationEnabled && !routes.include.includes(webBotAuthDirectoryPath)) {
1238
+ routes.include.push(webBotAuthDirectoryPath)
1239
+ }
1240
+
1071
1241
  // Cloudflare Pages rejects overlapping include rules (e.g. "/mcp" with "/*").
1072
1242
  // Keep only the catch-all when markdown negotiation is enabled.
1073
1243
  if (routes.include.includes('/*')) {
1074
- routes.include = routes.include.filter((route) => route !== '/mcp')
1244
+ routes.include = ['/*']
1075
1245
  }
1076
1246
 
1077
1247
  const markdownExcludes = [
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Web Bot Auth request signing helper.
3
+ *
4
+ * This utility prepares Signature-Agent, Signature-Input and Signature headers
5
+ * for outbound bot/agent requests following the Web Bot Auth profile.
6
+ */
7
+
8
+ const encoder = new TextEncoder()
9
+
10
+ function bytesToBase64 (bytes) {
11
+ let binary = ''
12
+ for (let i = 0; i < bytes.length; i++) {
13
+ binary += String.fromCharCode(bytes[i])
14
+ }
15
+
16
+ if (typeof btoa === 'function') {
17
+ return btoa(binary)
18
+ }
19
+
20
+ return Buffer.from(bytes).toString('base64')
21
+ }
22
+
23
+ function assertHttpsUrl (value, fieldName) {
24
+ let url
25
+ try {
26
+ url = new URL(value)
27
+ } catch {
28
+ throw new Error(`${fieldName} must be a valid URL`)
29
+ }
30
+
31
+ if (url.protocol !== 'https:') {
32
+ throw new Error(`${fieldName} must use https://`)
33
+ }
34
+
35
+ return url
36
+ }
37
+
38
+ async function importPrivateKey (privateJwk) {
39
+ if (!privateJwk || privateJwk.kty !== 'OKP' || privateJwk.crv !== 'Ed25519' || !privateJwk.d || !privateJwk.x) {
40
+ throw new Error('privateJwk must be an Ed25519 private JWK with kty=OKP, crv=Ed25519, d and x')
41
+ }
42
+
43
+ return crypto.subtle.importKey('jwk', privateJwk, { name: 'Ed25519' }, false, ['sign'])
44
+ }
45
+
46
+ function formatSignatureParams ({ label, keyId, created, expires }) {
47
+ return `${label}=("@authority" "signature-agent");created=${created};expires=${expires};keyid="${keyId}";alg="ed25519";tag="web-bot-auth"`
48
+ }
49
+
50
+ function formatSignatureBase ({ authority, signatureAgentHeader, params }) {
51
+ return `"@authority": ${authority}\n"signature-agent": ${signatureAgentHeader}\n"@signature-params": ${params}`
52
+ }
53
+
54
+ /**
55
+ * Build Web Bot Auth headers for an outbound request.
56
+ *
57
+ * @param {Object} options
58
+ * @param {string} options.url - Target request URL.
59
+ * @param {Object} options.privateJwk - Private Ed25519 JWK.
60
+ * @param {string} options.keyId - JWK thumbprint or configured key id.
61
+ * @param {string} options.signatureAgent - HTTPS URL of your signatures directory.
62
+ * @param {number} [options.expiresIn=60] - Signature validity window in seconds.
63
+ * @param {number} [options.createdAt] - Optional created timestamp (unix seconds).
64
+ * @param {string} [options.label='sig1'] - Signature label.
65
+ * @returns {Promise<Object>} Headers object with Signature-Agent, Signature-Input and Signature.
66
+ */
67
+ export async function createWebBotAuthHeaders ({
68
+ url,
69
+ privateJwk,
70
+ keyId,
71
+ signatureAgent,
72
+ expiresIn = 60,
73
+ createdAt,
74
+ label = 'sig1'
75
+ }) {
76
+ if (!keyId || typeof keyId !== 'string') {
77
+ throw new Error('keyId is required')
78
+ }
79
+
80
+ const target = assertHttpsUrl(url, 'url')
81
+ const signatureAgentUrl = assertHttpsUrl(signatureAgent, 'signatureAgent')
82
+ const signatureAgentHeader = `"${signatureAgentUrl.toString()}"`
83
+
84
+ const created = Number.isFinite(createdAt) ? Math.floor(createdAt) : Math.floor(Date.now() / 1000)
85
+ const ttl = Number.isFinite(expiresIn) ? Math.max(30, Math.floor(expiresIn)) : 60
86
+ const expires = created + ttl
87
+
88
+ const params = formatSignatureParams({ label, keyId, created, expires })
89
+ const paramsValue = params.slice(label.length + 1)
90
+ const base = formatSignatureBase({
91
+ authority: target.host,
92
+ signatureAgentHeader,
93
+ params: paramsValue
94
+ })
95
+
96
+ const privateKey = await importPrivateKey(privateJwk)
97
+ const signatureBytes = await crypto.subtle.sign('Ed25519', privateKey, encoder.encode(base))
98
+ const signature = `${label}=:${bytesToBase64(new Uint8Array(signatureBytes))}:`
99
+
100
+ return {
101
+ 'Signature-Agent': signatureAgentHeader,
102
+ 'Signature-Input': params,
103
+ Signature: signature
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Mutates a Headers object by adding Web Bot Auth headers.
109
+ *
110
+ * @param {Headers} headers - Headers instance to mutate.
111
+ * @param {Object} options - Same options accepted by createWebBotAuthHeaders.
112
+ * @returns {Promise<Headers>} The same headers instance with Web Bot Auth headers.
113
+ */
114
+ export async function applyWebBotAuthHeaders (headers, options) {
115
+ const signedHeaders = await createWebBotAuthHeaders(options)
116
+ for (const [name, value] of Object.entries(signedHeaders)) {
117
+ headers.set(name, value)
118
+ }
119
+
120
+ return headers
121
+ }