@conciergeprotocol/client 0.0.0-stage → 0.1.1
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 +87 -2
- package/package.json +33 -4
- package/src/client.js +446 -0
- package/src/door.js +120 -0
- package/src/errors.js +17 -0
- package/src/index.d.ts +88 -0
- package/src/index.js +2 -0
package/README.md
CHANGED
|
@@ -1,3 +1,88 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @conciergeprotocol/client
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Call a [Concierge Agent Protocol](https://conciergeprotocol.org) front door from JavaScript. Give it a domain. It reads `agent.txt`, posts one message, and waits for the company's reply.
|
|
4
|
+
|
|
5
|
+
The protocol is at [conciergeprotocol.org](https://conciergeprotocol.org). Source is at [github.com/conciergeprotocol/js](https://github.com/conciergeprotocol/js).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @conciergeprotocol/client
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Node.js 18 or newer.
|
|
14
|
+
|
|
15
|
+
## Ask
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
import { createClient } from '@conciergeprotocol/client'
|
|
19
|
+
|
|
20
|
+
const cap = createClient({ from: 'agent:my-assistant' })
|
|
21
|
+
|
|
22
|
+
const answer = await cap.ask('cosmicjs.com', 'Do you have a free plan?')
|
|
23
|
+
console.log(answer.reply)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`from` is `agent:` plus 1 to 64 letters, numbers, dots, underscores, or hyphens. It tells the company who is calling.
|
|
27
|
+
|
|
28
|
+
A follow-up sends only the new question. The company keeps the earlier turns.
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
const thread = cap.thread('cosmicjs.com')
|
|
32
|
+
await thread.ask('What is Cosmic?')
|
|
33
|
+
const price = await thread.ask('How much is the cheapest paid plan?')
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## When there is no front door
|
|
37
|
+
|
|
38
|
+
`discover` returns `null`. `ask` throws `NoDoorError`. That is the signal to browse the site instead.
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
import { createClient, NoDoorError } from '@conciergeprotocol/client'
|
|
42
|
+
|
|
43
|
+
const cap = createClient({ from: 'agent:my-assistant' })
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const answer = await cap.ask('example.com', 'What are your hours?')
|
|
47
|
+
console.log(answer.reply)
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error instanceof NoDoorError) {
|
|
50
|
+
// Browse the site.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Still working
|
|
56
|
+
|
|
57
|
+
`ask` waits up to two minutes. If the company is still answering, the message comes back with `status: 'working'` and no `reply`. Read it again with `get`. Do not write a reply while the status is `working`.
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
const message = await cap.ask('example.com', 'What are your hours?')
|
|
61
|
+
if (message.status === 'working') {
|
|
62
|
+
const later = await cap.get('example.com', message.id)
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
A `failed` message is a result, including `login_required`. It is not thrown.
|
|
67
|
+
|
|
68
|
+
## Account questions
|
|
69
|
+
|
|
70
|
+
`login` opens the company's page. `finishLogin` stores the token for that door, and the next `ask` sends it. Pass your own token store to `createClient` when the token should outlive the process.
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
const cap = createClient({
|
|
74
|
+
from: 'agent:my-assistant',
|
|
75
|
+
tokens: {
|
|
76
|
+
async get(origin) {},
|
|
77
|
+
async set(origin, token) {},
|
|
78
|
+
async delete(origin) {}
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
await cap.login('example.com')
|
|
83
|
+
await cap.finishLogin(callbackUrl)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
Apache 2.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@conciergeprotocol/client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Call any Concierge Agent Protocol front door.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./src/index.d.ts",
|
|
9
|
+
"import": "./src/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"license": "Apache-2.0",
|
|
19
|
+
"homepage": "https://conciergeprotocol.org",
|
|
20
|
+
"keywords": [
|
|
21
|
+
"concierge",
|
|
22
|
+
"cap",
|
|
23
|
+
"agents",
|
|
24
|
+
"protocol",
|
|
25
|
+
"ai"
|
|
26
|
+
],
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/conciergeprotocol/js.git",
|
|
30
|
+
"directory": "packages/client"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { CapError, NoDoorError } from './errors.js'
|
|
2
|
+
import { fetchDoor } from './door.js'
|
|
3
|
+
|
|
4
|
+
const MAX_CONTEXT = 4 * 1024
|
|
5
|
+
const SENSITIVE_KEY = /password|secret|token/i
|
|
6
|
+
const FROM = /^agent:[A-Za-z0-9_.-]{1,64}$/
|
|
7
|
+
|
|
8
|
+
function memoryCache(now) {
|
|
9
|
+
const store = new Map()
|
|
10
|
+
return {
|
|
11
|
+
get(key) {
|
|
12
|
+
const hit = store.get(key)
|
|
13
|
+
if (!hit || hit.expiresAt <= now()) {
|
|
14
|
+
store.delete(key)
|
|
15
|
+
return undefined
|
|
16
|
+
}
|
|
17
|
+
return hit
|
|
18
|
+
},
|
|
19
|
+
set(key, value) {
|
|
20
|
+
store.set(key, value)
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function assertFrom(from) {
|
|
26
|
+
if (typeof from !== 'string' || !FROM.test(from)) {
|
|
27
|
+
throw new CapError('invalid_request', 0, 'from must be agent:{id}.')
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function assertContext(context) {
|
|
32
|
+
if (context == null) return
|
|
33
|
+
if (typeof context !== 'object' || Array.isArray(context)) {
|
|
34
|
+
throw new CapError('invalid_request', 0, 'context must be an object.')
|
|
35
|
+
}
|
|
36
|
+
if (JSON.stringify(context).length > MAX_CONTEXT) {
|
|
37
|
+
throw new CapError('invalid_request', 0, 'context is too long.')
|
|
38
|
+
}
|
|
39
|
+
const walk = (value) => {
|
|
40
|
+
if (!value || typeof value !== 'object') return
|
|
41
|
+
for (const [key, child] of Object.entries(value)) {
|
|
42
|
+
if (SENSITIVE_KEY.test(key)) {
|
|
43
|
+
throw new CapError('invalid_request', 0, 'context cannot include a password, secret, or token.')
|
|
44
|
+
}
|
|
45
|
+
walk(child)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
walk(context)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseRetryAfter(header) {
|
|
52
|
+
if (!header) return null
|
|
53
|
+
const seconds = Number(header)
|
|
54
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds)
|
|
55
|
+
const date = Date.parse(header)
|
|
56
|
+
if (Number.isNaN(date)) return null
|
|
57
|
+
return Math.max(0, Math.round((date - Date.now()) / 1000))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function defaultCode(status) {
|
|
61
|
+
if (status === 401) return 'unauthorized'
|
|
62
|
+
if (status === 403) return 'forbidden'
|
|
63
|
+
if (status === 404) return 'not_found'
|
|
64
|
+
if (status === 409) return 'conflict'
|
|
65
|
+
if (status === 429) return 'rate_limited'
|
|
66
|
+
if (status === 503) return 'unavailable'
|
|
67
|
+
if (status === 400) return 'invalid_request'
|
|
68
|
+
return 'unavailable'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function errorFrom(response) {
|
|
72
|
+
let body = null
|
|
73
|
+
try {
|
|
74
|
+
body = await response.json()
|
|
75
|
+
} catch {
|
|
76
|
+
body = null
|
|
77
|
+
}
|
|
78
|
+
const code = body?.error?.code || defaultCode(response.status)
|
|
79
|
+
const message = body?.error?.message || 'Request failed.'
|
|
80
|
+
const retryAfter = parseRetryAfter(response.headers.get('retry-after'))
|
|
81
|
+
return new CapError(code, response.status, message, { retryAfter })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalize(body) {
|
|
85
|
+
if (!body || typeof body !== 'object' || typeof body.id !== 'string') {
|
|
86
|
+
throw new CapError('invalid_request', 0, 'The owner returned an unreadable message.')
|
|
87
|
+
}
|
|
88
|
+
if (body.status !== 'working' && body.status !== 'done' && body.status !== 'failed') {
|
|
89
|
+
throw new CapError('invalid_request', 0, 'The owner returned an unreadable message.')
|
|
90
|
+
}
|
|
91
|
+
const message = {
|
|
92
|
+
id: body.id,
|
|
93
|
+
status: body.status,
|
|
94
|
+
created_at: body.created_at,
|
|
95
|
+
finished_at: body.finished_at ?? null,
|
|
96
|
+
}
|
|
97
|
+
if (body.status === 'done' && typeof body.reply === 'string') message.reply = body.reply
|
|
98
|
+
if (body.status === 'failed' && body.error && typeof body.error === 'object') {
|
|
99
|
+
message.error = { code: String(body.error.code || 'failed'), message: String(body.error.message || '') }
|
|
100
|
+
}
|
|
101
|
+
if (body.status === 'done' && body.confirmation) message.confirmation = body.confirmation
|
|
102
|
+
return message
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function base64url(bytes) {
|
|
106
|
+
let binary = ''
|
|
107
|
+
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
108
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function pkcePair() {
|
|
112
|
+
const bytes = new Uint8Array(32)
|
|
113
|
+
crypto.getRandomValues(bytes)
|
|
114
|
+
const verifier = base64url(bytes)
|
|
115
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
|
|
116
|
+
return { verifier, challenge: base64url(new Uint8Array(digest)) }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function metadataUrl(oauth) {
|
|
120
|
+
const issuer = new URL(oauth)
|
|
121
|
+
const path = issuer.pathname.replace(/\/$/, '')
|
|
122
|
+
return new URL(`/.well-known/oauth-authorization-server${path}`, issuer.origin).toString()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function messagesUrl(door, id) {
|
|
126
|
+
const base = door.cap.replace(/\/$/, '')
|
|
127
|
+
return id ? `${base}/messages/${encodeURIComponent(id)}` : `${base}/messages`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function createClient(options = {}) {
|
|
131
|
+
const from = options.from
|
|
132
|
+
assertFrom(from)
|
|
133
|
+
const fetchImpl = options.fetch || globalThis.fetch
|
|
134
|
+
const tokens = options.tokens || null
|
|
135
|
+
const maxWaitMs = options.maxWaitMs ?? 120_000
|
|
136
|
+
const now = options.now || (() => Date.now())
|
|
137
|
+
const sleep = options.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
|
|
138
|
+
const cache = options.cache || memoryCache(now)
|
|
139
|
+
const pendingLogins = new Map()
|
|
140
|
+
|
|
141
|
+
async function cachedDoor(target) {
|
|
142
|
+
if (target && typeof target === 'object' && typeof target.cap === 'string') return target
|
|
143
|
+
const key = String(target || '').trim().toLowerCase()
|
|
144
|
+
const hit = await cache.get(key)
|
|
145
|
+
if (hit && hit.expiresAt > now()) return hit.value
|
|
146
|
+
let loaded
|
|
147
|
+
try {
|
|
148
|
+
loaded = await fetchDoor(target, fetchImpl)
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (err?.name === 'AbortError') throw err
|
|
151
|
+
throw new CapError('unavailable', 0, 'The front door could not be reached.')
|
|
152
|
+
}
|
|
153
|
+
await cache.set(key, { value: loaded.door, expiresAt: now() + loaded.ttl })
|
|
154
|
+
return loaded.door
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function doorFor(target) {
|
|
158
|
+
const door = await cachedDoor(target)
|
|
159
|
+
if (!door) throw new NoDoorError(typeof target === 'string' ? target : 'this target')
|
|
160
|
+
return door
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function originOf(door) {
|
|
164
|
+
return door.origin || new URL(door.cap).origin
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function currentToken(door) {
|
|
168
|
+
if (!tokens) return null
|
|
169
|
+
return (await tokens.get(originOf(door))) || null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function wait(ms, signal) {
|
|
173
|
+
if (signal?.aborted) throw new DOMException('The operation was aborted.', 'AbortError')
|
|
174
|
+
await sleep(ms)
|
|
175
|
+
if (signal?.aborted) throw new DOMException('The operation was aborted.', 'AbortError')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function refresh(token) {
|
|
179
|
+
const body = new URLSearchParams({
|
|
180
|
+
grant_type: 'refresh_token',
|
|
181
|
+
refresh_token: token.refreshToken,
|
|
182
|
+
client_id: token.clientId,
|
|
183
|
+
})
|
|
184
|
+
const response = await fetchImpl(token.tokenEndpoint, {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
187
|
+
body,
|
|
188
|
+
})
|
|
189
|
+
if (!response.ok) throw new CapError('unauthorized', response.status, 'The login could not be renewed.')
|
|
190
|
+
const json = await response.json()
|
|
191
|
+
if (!json?.access_token) throw new CapError('unauthorized', response.status, 'The login could not be renewed.')
|
|
192
|
+
return {
|
|
193
|
+
...token,
|
|
194
|
+
accessToken: json.access_token,
|
|
195
|
+
refreshToken: json.refresh_token || token.refreshToken,
|
|
196
|
+
scope: json.scope || token.scope,
|
|
197
|
+
expiresAt: json.expires_in ? now() + Number(json.expires_in) * 1000 : token.expiresAt,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function send(url, { method, json, token, idempotencyKey, signal }) {
|
|
202
|
+
const headers = {
|
|
203
|
+
'cap-version': '1.0',
|
|
204
|
+
'cap-from': from,
|
|
205
|
+
accept: 'application/json',
|
|
206
|
+
}
|
|
207
|
+
if (json) headers['content-type'] = 'application/json'
|
|
208
|
+
if (idempotencyKey) headers['idempotency-key'] = idempotencyKey
|
|
209
|
+
if (token?.accessToken) headers.authorization = `Bearer ${token.accessToken}`
|
|
210
|
+
return fetchImpl(url, {
|
|
211
|
+
method,
|
|
212
|
+
headers,
|
|
213
|
+
body: json ? JSON.stringify(json) : undefined,
|
|
214
|
+
signal,
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function call(door, makeRequest, { signal, deadline, allowNetworkRetry }) {
|
|
219
|
+
let token = await currentToken(door)
|
|
220
|
+
let refreshed = false
|
|
221
|
+
let networkRetried = false
|
|
222
|
+
let rateRetried = false
|
|
223
|
+
for (;;) {
|
|
224
|
+
let response
|
|
225
|
+
try {
|
|
226
|
+
response = await makeRequest(token)
|
|
227
|
+
} catch (err) {
|
|
228
|
+
if (err?.name === 'AbortError') throw err
|
|
229
|
+
if (allowNetworkRetry && !networkRetried) {
|
|
230
|
+
networkRetried = true
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
throw new CapError('unavailable', 0, 'The front door could not be reached.')
|
|
234
|
+
}
|
|
235
|
+
if (response.status === 401 && tokens) {
|
|
236
|
+
if (!refreshed && token?.refreshToken && token.tokenEndpoint) {
|
|
237
|
+
refreshed = true
|
|
238
|
+
try {
|
|
239
|
+
token = await refresh(token)
|
|
240
|
+
await tokens.set(originOf(door), token)
|
|
241
|
+
continue
|
|
242
|
+
} catch {
|
|
243
|
+
await tokens.delete(originOf(door))
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
await tokens.delete(originOf(door))
|
|
247
|
+
}
|
|
248
|
+
throw await errorFrom(response)
|
|
249
|
+
}
|
|
250
|
+
if ((response.status === 429 || response.status === 503) && !rateRetried) {
|
|
251
|
+
const retryAfter = parseRetryAfter(response.headers.get('retry-after')) ?? 1
|
|
252
|
+
const delay = Math.max(1, retryAfter) * 1000
|
|
253
|
+
if (deadline - now() >= delay) {
|
|
254
|
+
rateRetried = true
|
|
255
|
+
await wait(delay, signal)
|
|
256
|
+
continue
|
|
257
|
+
}
|
|
258
|
+
throw await errorFrom(response)
|
|
259
|
+
}
|
|
260
|
+
if (response.status >= 400) throw await errorFrom(response)
|
|
261
|
+
return response
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function readMessage(door, id, { signal, deadline }) {
|
|
266
|
+
const response = await call(door, (token) => send(messagesUrl(door, id), { method: 'GET', token, signal }), {
|
|
267
|
+
signal,
|
|
268
|
+
deadline,
|
|
269
|
+
allowNetworkRetry: true,
|
|
270
|
+
})
|
|
271
|
+
const retryAfter = parseRetryAfter(response.headers.get('retry-after'))
|
|
272
|
+
const message = normalize(await response.json())
|
|
273
|
+
return { message, retryAfter }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function ask(target, message, askOptions = {}) {
|
|
277
|
+
if (typeof message !== 'string' || !message.trim()) {
|
|
278
|
+
throw new CapError('invalid_request', 0, 'message is required.')
|
|
279
|
+
}
|
|
280
|
+
assertContext(askOptions.context)
|
|
281
|
+
const door = await doorFor(target)
|
|
282
|
+
const deadline = now() + maxWaitMs
|
|
283
|
+
const idempotencyKey = askOptions.idempotencyKey || crypto.randomUUID()
|
|
284
|
+
const payload = { message }
|
|
285
|
+
if (askOptions.context) payload.context = askOptions.context
|
|
286
|
+
if (askOptions.previousMessageId) payload.previous_message_id = askOptions.previousMessageId
|
|
287
|
+
const response = await call(
|
|
288
|
+
door,
|
|
289
|
+
(token) => send(messagesUrl(door), { method: 'POST', json: payload, token, idempotencyKey, signal: askOptions.signal }),
|
|
290
|
+
{ signal: askOptions.signal, deadline, allowNetworkRetry: true }
|
|
291
|
+
)
|
|
292
|
+
let current = normalize(await response.json())
|
|
293
|
+
let retryAfter = parseRetryAfter(response.headers.get('retry-after'))
|
|
294
|
+
while (current.status === 'working') {
|
|
295
|
+
const delay = Math.max(1, retryAfter ?? 1) * 1000
|
|
296
|
+
if (deadline - now() < delay) return current
|
|
297
|
+
await wait(delay, askOptions.signal)
|
|
298
|
+
const next = await readMessage(door, current.id, { signal: askOptions.signal, deadline })
|
|
299
|
+
current = next.message
|
|
300
|
+
retryAfter = next.retryAfter
|
|
301
|
+
}
|
|
302
|
+
return current
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function get(target, id) {
|
|
306
|
+
if (typeof id !== 'string' || !id) throw new CapError('invalid_request', 0, 'message id is required.')
|
|
307
|
+
const door = await doorFor(target)
|
|
308
|
+
const read = await readMessage(door, id, { deadline: now() + maxWaitMs })
|
|
309
|
+
return read.message
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function thread(target) {
|
|
313
|
+
let previousId = null
|
|
314
|
+
const api = {
|
|
315
|
+
restarted: false,
|
|
316
|
+
async ask(message, askOptions = {}) {
|
|
317
|
+
api.restarted = false
|
|
318
|
+
const options = { ...askOptions, previousMessageId: previousId || undefined }
|
|
319
|
+
try {
|
|
320
|
+
const result = await ask(target, message, options)
|
|
321
|
+
previousId = result.id
|
|
322
|
+
return result
|
|
323
|
+
} catch (err) {
|
|
324
|
+
if (err instanceof CapError && err.code === 'not_found') previousId = null
|
|
325
|
+
if (err instanceof CapError && err.code === 'conflict' && previousId) {
|
|
326
|
+
previousId = null
|
|
327
|
+
api.restarted = true
|
|
328
|
+
const { previousMessageId: _ignored, ...rest } = askOptions
|
|
329
|
+
const result = await ask(target, message, rest)
|
|
330
|
+
previousId = result.id
|
|
331
|
+
return result
|
|
332
|
+
}
|
|
333
|
+
throw err
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
}
|
|
337
|
+
return api
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function login(target, loginOptions) {
|
|
341
|
+
const door = await doorFor(target)
|
|
342
|
+
if (!door.oauth) throw new CapError('invalid_request', 0, 'This front door has no login.')
|
|
343
|
+
const metaResponse = await fetchImpl(metadataUrl(door.oauth), { headers: { accept: 'application/json' } })
|
|
344
|
+
if (!metaResponse.ok) throw new CapError('unavailable', metaResponse.status, 'The login server could not be read.')
|
|
345
|
+
const metadata = await metaResponse.json()
|
|
346
|
+
if (!metadata?.authorization_endpoint || !metadata?.token_endpoint) {
|
|
347
|
+
throw new CapError('invalid_request', 0, 'This front door has no redirect login.')
|
|
348
|
+
}
|
|
349
|
+
const { verifier, challenge } = await pkcePair()
|
|
350
|
+
const state = crypto.randomUUID()
|
|
351
|
+
const url = new URL(metadata.authorization_endpoint)
|
|
352
|
+
url.searchParams.set('response_type', 'code')
|
|
353
|
+
url.searchParams.set('client_id', loginOptions.clientId)
|
|
354
|
+
url.searchParams.set('redirect_uri', loginOptions.redirectUri)
|
|
355
|
+
url.searchParams.set('state', state)
|
|
356
|
+
url.searchParams.set('code_challenge', challenge)
|
|
357
|
+
url.searchParams.set('code_challenge_method', 'S256')
|
|
358
|
+
if (loginOptions.scope) url.searchParams.set('scope', loginOptions.scope)
|
|
359
|
+
const result = new Promise((resolve, reject) => {
|
|
360
|
+
pendingLogins.set(state, {
|
|
361
|
+
resolve,
|
|
362
|
+
reject,
|
|
363
|
+
verifier,
|
|
364
|
+
clientId: loginOptions.clientId,
|
|
365
|
+
redirectUri: loginOptions.redirectUri,
|
|
366
|
+
door,
|
|
367
|
+
metadata,
|
|
368
|
+
})
|
|
369
|
+
})
|
|
370
|
+
try {
|
|
371
|
+
await loginOptions.open(url.toString())
|
|
372
|
+
} catch (err) {
|
|
373
|
+
pendingLogins.delete(state)
|
|
374
|
+
throw err
|
|
375
|
+
}
|
|
376
|
+
return result
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function finishLogin(callbackUrl) {
|
|
380
|
+
const url = new URL(callbackUrl)
|
|
381
|
+
const state = url.searchParams.get('state')
|
|
382
|
+
const pending = state ? pendingLogins.get(state) : null
|
|
383
|
+
if (!pending) throw new CapError('invalid_request', 0, 'This login session is unknown.')
|
|
384
|
+
pendingLogins.delete(state)
|
|
385
|
+
const fail = (error) => {
|
|
386
|
+
pending.reject(error)
|
|
387
|
+
throw error
|
|
388
|
+
}
|
|
389
|
+
const oauthError = url.searchParams.get('error')
|
|
390
|
+
if (oauthError) {
|
|
391
|
+
fail(new CapError(oauthError, 0, url.searchParams.get('error_description') || 'Login was denied.'))
|
|
392
|
+
}
|
|
393
|
+
const code = url.searchParams.get('code')
|
|
394
|
+
if (!code) fail(new CapError('invalid_request', 0, 'The login redirect had no code.'))
|
|
395
|
+
try {
|
|
396
|
+
const body = new URLSearchParams({
|
|
397
|
+
grant_type: 'authorization_code',
|
|
398
|
+
code,
|
|
399
|
+
redirect_uri: pending.redirectUri,
|
|
400
|
+
client_id: pending.clientId,
|
|
401
|
+
code_verifier: pending.verifier,
|
|
402
|
+
})
|
|
403
|
+
const response = await fetchImpl(pending.metadata.token_endpoint, {
|
|
404
|
+
method: 'POST',
|
|
405
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
406
|
+
body,
|
|
407
|
+
})
|
|
408
|
+
if (!response.ok) throw new CapError('unauthorized', response.status, 'The login could not be completed.')
|
|
409
|
+
const json = await response.json()
|
|
410
|
+
if (!json?.access_token) throw new CapError('unauthorized', response.status, 'The login could not be completed.')
|
|
411
|
+
const token = {
|
|
412
|
+
accessToken: json.access_token,
|
|
413
|
+
refreshToken: json.refresh_token,
|
|
414
|
+
scope: json.scope,
|
|
415
|
+
expiresAt: json.expires_in ? now() + Number(json.expires_in) * 1000 : undefined,
|
|
416
|
+
clientId: pending.clientId,
|
|
417
|
+
tokenEndpoint: pending.metadata.token_endpoint,
|
|
418
|
+
revocationEndpoint: pending.metadata.revocation_endpoint,
|
|
419
|
+
}
|
|
420
|
+
if (tokens) await tokens.set(originOf(pending.door), token)
|
|
421
|
+
pending.resolve(token)
|
|
422
|
+
return token
|
|
423
|
+
} catch (err) {
|
|
424
|
+
pending.reject(err)
|
|
425
|
+
throw err
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function logout(target) {
|
|
430
|
+
const door = await doorFor(target)
|
|
431
|
+
if (!tokens) return
|
|
432
|
+
const key = originOf(door)
|
|
433
|
+
const token = await tokens.get(key)
|
|
434
|
+
if (token?.refreshToken && token.revocationEndpoint) {
|
|
435
|
+
const body = new URLSearchParams({ token: token.refreshToken, client_id: token.clientId || '' })
|
|
436
|
+
await fetchImpl(token.revocationEndpoint, {
|
|
437
|
+
method: 'POST',
|
|
438
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
439
|
+
body,
|
|
440
|
+
}).catch(() => {})
|
|
441
|
+
}
|
|
442
|
+
await tokens.delete(key)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return { discover: cachedDoor, ask, get, thread, login, finishLogin, logout }
|
|
446
|
+
}
|
package/src/door.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const MAX_FILE = 16 * 1024
|
|
2
|
+
const FOUND_TTL = 60 * 60 * 1000
|
|
3
|
+
const MISSING_TTL = 10 * 60 * 1000
|
|
4
|
+
const KNOWN = new Set(['protocol', 'name', 'chat', 'cap', 'oauth', 'spec'])
|
|
5
|
+
|
|
6
|
+
export function isLoopback(hostname) {
|
|
7
|
+
const host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '')
|
|
8
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function siteHost(hostname) {
|
|
12
|
+
return String(hostname || '').toLowerCase().replace(/\.$/, '').replace(/^www\./, '')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function sameSite(left, right) {
|
|
16
|
+
const a = siteHost(left)
|
|
17
|
+
const b = siteHost(right)
|
|
18
|
+
if (!a || !b) return false
|
|
19
|
+
return a === b || a.endsWith(`.${b}`) || b.endsWith(`.${a}`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function allowedProtocol(url) {
|
|
23
|
+
return url.protocol === 'https:' || (url.protocol === 'http:' && isLoopback(url.hostname))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function agentTxtUrl(input) {
|
|
27
|
+
const raw = String(input || '').trim()
|
|
28
|
+
if (!raw) return null
|
|
29
|
+
let url
|
|
30
|
+
try {
|
|
31
|
+
url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`)
|
|
32
|
+
} catch {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
if (!allowedProtocol(url)) return null
|
|
36
|
+
url.pathname = '/agent.txt'
|
|
37
|
+
url.search = ''
|
|
38
|
+
url.hash = ''
|
|
39
|
+
url.username = ''
|
|
40
|
+
url.password = ''
|
|
41
|
+
return url
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function cacheTtl(header, fallback) {
|
|
45
|
+
const match = /max-age=(\d+)/i.exec(header || '')
|
|
46
|
+
return match ? Number(match[1]) * 1000 : fallback
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseAgentTxt(text, origin) {
|
|
50
|
+
const fields = {}
|
|
51
|
+
const extra = {}
|
|
52
|
+
for (const line of String(text || '').split(/\r?\n/)) {
|
|
53
|
+
const trimmed = line.trim()
|
|
54
|
+
if (!trimmed) continue
|
|
55
|
+
const split = trimmed.indexOf(':')
|
|
56
|
+
if (split <= 0) continue
|
|
57
|
+
const key = trimmed.slice(0, split).trim().toLowerCase()
|
|
58
|
+
const value = trimmed.slice(split + 1).trim()
|
|
59
|
+
if (!key || !value) continue
|
|
60
|
+
if (KNOWN.has(key)) fields[key] = value
|
|
61
|
+
else extra[key] = value
|
|
62
|
+
}
|
|
63
|
+
let cap
|
|
64
|
+
try {
|
|
65
|
+
const url = new URL(fields.cap || '')
|
|
66
|
+
if (!allowedProtocol(url) || url.username || url.password) return null
|
|
67
|
+
cap = url.toString().replace(/\/$/, '')
|
|
68
|
+
} catch {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
protocol: fields.protocol || null,
|
|
73
|
+
name: fields.name || null,
|
|
74
|
+
chat: fields.chat || null,
|
|
75
|
+
cap,
|
|
76
|
+
oauth: fields.oauth || null,
|
|
77
|
+
spec: fields.spec || null,
|
|
78
|
+
origin,
|
|
79
|
+
extra,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function readDoor(response, finalUrl) {
|
|
84
|
+
if (response.status !== 200) return { door: null, ttl: MISSING_TTL }
|
|
85
|
+
const type = response.headers.get('content-type') || ''
|
|
86
|
+
if (/text\/html|application\/xhtml/i.test(type)) return { door: null, ttl: MISSING_TTL }
|
|
87
|
+
const length = Number(response.headers.get('content-length'))
|
|
88
|
+
if (Number.isFinite(length) && length > MAX_FILE) return { door: null, ttl: MISSING_TTL }
|
|
89
|
+
const text = await response.text()
|
|
90
|
+
if (text.length > MAX_FILE || text.trimStart().startsWith('<')) return { door: null, ttl: MISSING_TTL }
|
|
91
|
+
const door = parseAgentTxt(text, new URL(finalUrl).origin)
|
|
92
|
+
if (!door) return { door: null, ttl: MISSING_TTL }
|
|
93
|
+
return { door, ttl: cacheTtl(response.headers.get('cache-control'), FOUND_TTL) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function fetchDoor(input, fetchImpl) {
|
|
97
|
+
const start = agentTxtUrl(input)
|
|
98
|
+
if (!start) return { door: null, ttl: MISSING_TTL }
|
|
99
|
+
let current = start
|
|
100
|
+
for (let hop = 0; hop < 5; hop += 1) {
|
|
101
|
+
const response = await fetchImpl(current, {
|
|
102
|
+
redirect: 'manual',
|
|
103
|
+
headers: { accept: 'text/plain, text/markdown, */*' },
|
|
104
|
+
})
|
|
105
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
106
|
+
const location = response.headers.get('location')
|
|
107
|
+
if (!location) return { door: null, ttl: MISSING_TTL }
|
|
108
|
+
const next = new URL(location, current)
|
|
109
|
+
if (!allowedProtocol(next) || !sameSite(start.hostname, next.hostname)) {
|
|
110
|
+
return { door: null, ttl: MISSING_TTL }
|
|
111
|
+
}
|
|
112
|
+
next.username = ''
|
|
113
|
+
next.password = ''
|
|
114
|
+
current = next
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
return readDoor(response, current)
|
|
118
|
+
}
|
|
119
|
+
return { door: null, ttl: MISSING_TTL }
|
|
120
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export class CapError extends Error {
|
|
2
|
+
constructor(code, status, message, options = {}) {
|
|
3
|
+
super(message)
|
|
4
|
+
this.name = 'CapError'
|
|
5
|
+
this.code = code
|
|
6
|
+
this.status = status
|
|
7
|
+
if (options.retryAfter != null) this.retryAfter = options.retryAfter
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class NoDoorError extends Error {
|
|
12
|
+
constructor(domain) {
|
|
13
|
+
super(`${domain} has no CAP front door.`)
|
|
14
|
+
this.name = 'NoDoorError'
|
|
15
|
+
this.domain = domain
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export class CapError extends Error {
|
|
2
|
+
code: string
|
|
3
|
+
status: number
|
|
4
|
+
retryAfter?: number
|
|
5
|
+
constructor(code: string, status: number, message: string, options?: { retryAfter?: number })
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class NoDoorError extends Error {
|
|
9
|
+
domain: string
|
|
10
|
+
constructor(domain: string)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type Door = {
|
|
14
|
+
protocol: string | null
|
|
15
|
+
name: string | null
|
|
16
|
+
chat: string | null
|
|
17
|
+
cap: string
|
|
18
|
+
oauth: string | null
|
|
19
|
+
spec: string | null
|
|
20
|
+
origin: string
|
|
21
|
+
extra: Record<string, string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type Message = {
|
|
25
|
+
id: string
|
|
26
|
+
status: 'working' | 'done' | 'failed'
|
|
27
|
+
reply?: string
|
|
28
|
+
error?: { code: string; message: string }
|
|
29
|
+
confirmation?: unknown
|
|
30
|
+
created_at?: string
|
|
31
|
+
finished_at?: string | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type Token = {
|
|
35
|
+
accessToken: string
|
|
36
|
+
refreshToken?: string
|
|
37
|
+
scope?: string
|
|
38
|
+
expiresAt?: number
|
|
39
|
+
clientId?: string
|
|
40
|
+
tokenEndpoint?: string
|
|
41
|
+
revocationEndpoint?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type TokenStore = {
|
|
45
|
+
get(origin: string): Token | undefined | Promise<Token | undefined>
|
|
46
|
+
set(origin: string, token: Token): void | Promise<void>
|
|
47
|
+
delete(origin: string): void | Promise<void>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type AskOptions = {
|
|
51
|
+
context?: Record<string, unknown>
|
|
52
|
+
previousMessageId?: string
|
|
53
|
+
idempotencyKey?: string
|
|
54
|
+
signal?: AbortSignal
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type Thread = {
|
|
58
|
+
restarted: boolean
|
|
59
|
+
ask(message: string, options?: AskOptions): Promise<Message>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type Client = {
|
|
63
|
+
discover(domain: string): Promise<Door | null>
|
|
64
|
+
ask(target: string | Door, message: string, options?: AskOptions): Promise<Message>
|
|
65
|
+
get(target: string | Door, id: string): Promise<Message>
|
|
66
|
+
thread(target: string | Door): Thread
|
|
67
|
+
login(target: string | Door, options: {
|
|
68
|
+
clientId: string
|
|
69
|
+
redirectUri: string
|
|
70
|
+
open: (url: string) => void | Promise<void>
|
|
71
|
+
scope?: string
|
|
72
|
+
}): Promise<Token>
|
|
73
|
+
finishLogin(callbackUrl: string): Promise<Token>
|
|
74
|
+
logout(target: string | Door): Promise<void>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createClient(options: {
|
|
78
|
+
from: string
|
|
79
|
+
fetch?: typeof fetch
|
|
80
|
+
tokens?: TokenStore
|
|
81
|
+
maxWaitMs?: number
|
|
82
|
+
cache?: {
|
|
83
|
+
get(key: string): unknown
|
|
84
|
+
set(key: string, value: unknown): unknown
|
|
85
|
+
}
|
|
86
|
+
now?: () => number
|
|
87
|
+
sleep?: (ms: number) => Promise<void> | void
|
|
88
|
+
}): Client
|
package/src/index.js
ADDED