@conciergeprotocol/client 0.0.0-stage → 0.1.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/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "@conciergeprotocol/client",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/conciergeprotocol/js.git",
22
+ "directory": "packages/client"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ }
27
+ }
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
@@ -0,0 +1,2 @@
1
+ export { CapError, NoDoorError } from './errors.js'
2
+ export { createClient } from './client.js'
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.