@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 +58 -0
- package/bin/docsector.js +16 -1
- package/package.json +2 -1
- package/src/index.js +21 -0
- package/src/quasar.factory.js +175 -5
- package/src/web-bot-auth/index.js +121 -0
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.
|
|
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.
|
|
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
|
}
|
package/src/quasar.factory.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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
|
+
}
|