@conciergeprotocol/server 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.
@@ -0,0 +1,375 @@
1
+ import { createServer } from 'node:http'
2
+ import { fail, readBody, requestOrigin, send } from './http.mjs'
3
+ import { newConfirmationId, newMessageId, startOfUtcDay } from './store.mjs'
4
+
5
+ const MAX_MESSAGE = 8000
6
+ const MAX_SUMMARY = 300
7
+ const CONFIRM_TTL_MS = 10 * 60_000
8
+
9
+ export function publicMessage(msg, confirmation = null) {
10
+ const view = { id: msg.id, status: msg.status }
11
+ if (msg.status === 'done') view.reply = msg.reply ?? ''
12
+ if (msg.status === 'failed') view.error = msg.error || { code: 'failed', message: 'The agent could not answer.' }
13
+ if (msg.status === 'done' && confirmation) view.confirmation = confirmation
14
+ view.created_at = msg.created_at
15
+ view.finished_at = msg.finished_at || null
16
+ return view
17
+ }
18
+
19
+ /** A pending request past its time reads as expired, and only a pending one carries the link. */
20
+ export function publicConfirmation(c, url) {
21
+ const status = c.status === 'pending' && Date.parse(c.expires_at) <= Date.now() ? 'expired' : c.status
22
+ const view = { id: c.id, status }
23
+ if (status === 'pending') view.url = url
24
+ view.summary = c.summary
25
+ view.authorization_details = c.authorization_details
26
+ view.expires_at = c.expires_at
27
+ if ((status === 'done' || status === 'failed') && c.result) view.result = c.result
28
+ return view
29
+ }
30
+
31
+ function bearer(req) {
32
+ const match = String(req.headers.authorization || '').match(/^Bearer\s+(\S+)$/i)
33
+ return match ? match[1] : ''
34
+ }
35
+
36
+ function callerId(req) {
37
+ const match = String(req.headers['cap-from'] || '').match(/^agent:([A-Za-z0-9_.-]{1,64})$/)
38
+ return match ? match[1] : ''
39
+ }
40
+
41
+ function outcome(value) {
42
+ if (value?.error) {
43
+ return {
44
+ status: 'failed',
45
+ reply: null,
46
+ error: { code: String(value.error.code || 'failed'), message: String(value.error.message || 'The agent could not answer.') },
47
+ }
48
+ }
49
+ if (typeof value?.reply === 'string') return { status: 'done', reply: value.reply, error: null }
50
+ return { status: 'failed', reply: null, error: { code: 'failed', message: 'The agent could not answer.' } }
51
+ }
52
+
53
+ /**
54
+ * `answer({ message, context, history, account, scope, caller })` resolves to `{ reply }` or `{ error: { code, message } }`.
55
+ * `history` is the earlier turns of the thread, oldest first, from the owner's own records.
56
+ * `scope` is `read` or `write` with a token, and null without one.
57
+ * To hold an action for the person's approval, it resolves to
58
+ * `{ confirm: { summary, authorization_details }, reply? }` instead.
59
+ * It runs on the owner. Nothing it uses is returned except the reply text.
60
+ */
61
+ export function createCapServer({
62
+ store,
63
+ agent,
64
+ answer,
65
+ waitMs = 20_000,
66
+ retryAfter = 5,
67
+ retentionHours = 24,
68
+ dailyLimit = 0,
69
+ maxTurns = 20,
70
+ oauth = null,
71
+ publicUrl = '',
72
+ extraRoutes,
73
+ }) {
74
+ async function onRequest(req, res) {
75
+ try {
76
+ await route(req, res)
77
+ } catch (err) {
78
+ if (res.headersSent) return
79
+ const status = err.status || 500
80
+ fail(res, status, status === 400 ? 'invalid_request' : 'failed', status === 400 ? err.message : 'Request failed.')
81
+ }
82
+ }
83
+
84
+ const server = createServer(onRequest)
85
+
86
+ async function view(msg, origin) {
87
+ if (msg.status !== 'done' || !msg.confirmation_id || !oauth) return publicMessage(msg)
88
+ const confirmation = await store.confirmationById(msg.confirmation_id)
89
+ return publicMessage(msg, confirmation ? publicConfirmation(confirmation, oauth.confirmUrl(origin, confirmation.id)) : null)
90
+ }
91
+
92
+ async function respond(res, msg, origin) {
93
+ if (msg.status === 'working') {
94
+ send(res, 202, publicMessage(msg), {
95
+ headers: { location: `/cap/v1/messages/${msg.id}`, 'retry-after': String(retryAfter) },
96
+ })
97
+ return
98
+ }
99
+ send(res, 200, await view(msg, origin))
100
+ }
101
+
102
+ const failedWith = (code, message) => ({ status: 'failed', reply: null, error: { code, message } })
103
+
104
+ // The action is not run here. It waits for the person on the confirm page.
105
+ async function hold(value, msg, scope, origin) {
106
+ if (!oauth?.canConfirm || !msg.account) {
107
+ return { result: failedWith('failed', `${agent.name} cannot hold an action for approval.`) }
108
+ }
109
+ if (scope !== 'write') {
110
+ return { result: failedWith('write_required', 'This login can read but not make changes. Log in again and allow changes.') }
111
+ }
112
+ const summary = String(value.confirm.summary || '').trim().slice(0, MAX_SUMMARY)
113
+ if (!summary) return { result: failedWith('failed', 'The agent could not answer.') }
114
+ const details = Array.isArray(value.confirm.authorization_details) ? value.confirm.authorization_details : []
115
+ const id = newConfirmationId()
116
+ const now = Date.now()
117
+ await store.insertConfirmation({
118
+ id,
119
+ caller_id: msg.caller_id,
120
+ account: msg.account,
121
+ client_id: msg.client_id,
122
+ message_id: msg.id,
123
+ summary,
124
+ authorization_details: details,
125
+ expires_at: new Date(now + CONFIRM_TTL_MS).toISOString(),
126
+ created_at: new Date(now).toISOString(),
127
+ })
128
+ const lead = typeof value.reply === 'string' && value.reply.trim() ? value.reply.trim() : `${summary} This needs your OK first:`
129
+ return { id, result: { status: 'done', reply: `${lead} ${oauth.confirmUrl(origin, id)}`, error: null } }
130
+ }
131
+
132
+ function expired(msg) {
133
+ return Boolean(msg.finished_at) && Date.now() - Date.parse(msg.finished_at) > retentionHours * 3_600_000
134
+ }
135
+
136
+ // A thread may move from no token to a token, never from one account to another.
137
+ function canContinue(prev, caller, account, client) {
138
+ if (!prev || prev.caller_id !== caller || expired(prev)) return false
139
+ if (!prev.account && !prev.client_id) return true
140
+ return prev.account === account && prev.client_id === client
141
+ }
142
+
143
+ async function threadFor(previousId, caller, account, client) {
144
+ const first = await store.byId(previousId)
145
+ if (!canContinue(first, caller, account, client)) {
146
+ return { status: 404, code: 'not_found', message: 'previous_message_id was not found.' }
147
+ }
148
+ if (first.status === 'working') {
149
+ return { status: 409, code: 'conflict', message: 'The previous message is still working.' }
150
+ }
151
+ const turns = []
152
+ let cursor = first
153
+ while (cursor) {
154
+ if (turns.length >= maxTurns) {
155
+ return { status: 409, code: 'conflict', message: 'This conversation is too long. Start a new one.' }
156
+ }
157
+ turns.unshift({ message: cursor.message, context: cursor.context, reply: cursor.status === 'done' ? cursor.reply : null })
158
+ cursor = cursor.previous_id ? await store.byId(cursor.previous_id) : null
159
+ }
160
+ return { history: turns }
161
+ }
162
+
163
+ async function grantFor(req, caller) {
164
+ if (!req.headers.authorization) return { grant: null }
165
+ const grant = await store.grantByToken(bearer(req))
166
+ if (!grant || grant.kind === 'refresh') return { status: 401, code: 'unauthorized', message: 'The token is invalid, expired, or revoked.' }
167
+ if (grant.client_id !== caller) return { status: 403, code: 'forbidden', message: 'This token was issued to a different agent.' }
168
+ return { grant }
169
+ }
170
+
171
+ async function route(req, res) {
172
+ const url = new URL(req.url || '/', 'http://localhost')
173
+ if (req.method === 'OPTIONS') {
174
+ send(res, 204, null, { raw: '' })
175
+ return
176
+ }
177
+ if (req.method === 'GET' && url.pathname === '/health') {
178
+ send(res, 200, { ok: true })
179
+ return
180
+ }
181
+ const origin = requestOrigin(req, publicUrl)
182
+ if (req.method === 'GET' && url.pathname === '/agent.txt') {
183
+ const oauthLine = agent.oauth || (oauth ? oauth.issuer(origin) : '')
184
+ const lines = [
185
+ 'Protocol: CAP/1.0',
186
+ `Name: ${agent.name}`,
187
+ agent.chat ? `Chat: ${agent.chat}` : '',
188
+ `CAP: ${origin}/cap/v1`,
189
+ oauthLine ? `OAuth: ${oauthLine}` : '',
190
+ `Spec: ${agent.spec || 'https://conciergeprotocol.org/cap.txt'}`,
191
+ ].filter(Boolean)
192
+ send(res, 200, null, { type: 'text/plain; charset=utf-8', raw: `${lines.join('\n')}\n` })
193
+ return
194
+ }
195
+ if (oauth && (await oauth.handle(req, res, url, origin))) return
196
+ if (extraRoutes && (await extraRoutes(req, res, url))) return
197
+
198
+ if (!url.pathname.startsWith('/cap/v1/')) {
199
+ fail(res, 404, 'not_found', 'Not found.')
200
+ return
201
+ }
202
+ if (req.headers['cap-version'] !== '1.0') {
203
+ fail(res, 400, 'version_not_supported', 'CAP-Version must be 1.0.')
204
+ return
205
+ }
206
+ const caller = callerId(req)
207
+ if (!caller) {
208
+ fail(res, 400, 'invalid_request', 'CAP-From must be agent:{id}.')
209
+ return
210
+ }
211
+
212
+ if (req.method === 'POST' && url.pathname === '/cap/v1/messages') {
213
+ await sendMessage(req, res, caller, origin)
214
+ return
215
+ }
216
+ const readMatch = url.pathname.match(/^\/cap\/v1\/messages\/([^/]+)$/)
217
+ if (req.method === 'GET' && readMatch) {
218
+ await readMessage(req, res, caller, decodeURIComponent(readMatch[1]), origin)
219
+ return
220
+ }
221
+ const confirmMatch = url.pathname.match(/^\/cap\/v1\/confirmations\/([^/]+)$/)
222
+ if (req.method === 'GET' && confirmMatch) {
223
+ await readConfirmation(req, res, caller, decodeURIComponent(confirmMatch[1]), origin)
224
+ return
225
+ }
226
+ fail(res, 404, 'not_found', 'Not found.')
227
+ }
228
+
229
+ async function sendMessage(req, res, caller, origin) {
230
+ if (agent.status && agent.status !== 'active') {
231
+ fail(res, 503, 'unavailable', `${agent.name} is not answering right now.`)
232
+ return
233
+ }
234
+ const access = await grantFor(req, caller)
235
+ if (!access.grant && access.status) {
236
+ fail(res, access.status, access.code, access.message)
237
+ return
238
+ }
239
+
240
+ let body
241
+ try {
242
+ body = JSON.parse(await readBody(req) || '{}')
243
+ } catch {
244
+ fail(res, 400, 'invalid_request', 'Body must be JSON.')
245
+ return
246
+ }
247
+ const text = typeof body.message === 'string' ? body.message.trim() : ''
248
+ if (!text || text.length > MAX_MESSAGE) {
249
+ fail(res, 400, 'invalid_request', `message must be text, up to ${MAX_MESSAGE} characters.`)
250
+ return
251
+ }
252
+ if (body.context != null && (typeof body.context !== 'object' || Array.isArray(body.context))) {
253
+ fail(res, 400, 'invalid_request', 'context must be an object.')
254
+ return
255
+ }
256
+ const previousId = body.previous_message_id ?? null
257
+ if (previousId != null && (typeof previousId !== 'string' || !previousId)) {
258
+ fail(res, 400, 'invalid_request', 'previous_message_id must be a message id.')
259
+ return
260
+ }
261
+
262
+ const account = access.grant?.account || ''
263
+ const client = access.grant?.client_id || ''
264
+ const scope = access.grant ? (access.grant.scope === 'write' ? 'write' : 'read') : null
265
+ const key = String(req.headers['idempotency-key'] || '')
266
+ if (key && !/^[A-Za-z0-9_.:-]{1,128}$/.test(key)) {
267
+ fail(res, 400, 'invalid_request', 'Idempotency-Key is invalid.')
268
+ return
269
+ }
270
+ if (key) {
271
+ const existing = await store.byIdempotency({ callerId: caller, account, clientId: client, key })
272
+ if (existing) {
273
+ await respond(res, existing, origin)
274
+ return
275
+ }
276
+ }
277
+
278
+ let history = []
279
+ if (previousId) {
280
+ const thread = await threadFor(previousId, caller, account, client)
281
+ if (thread.status) {
282
+ fail(res, thread.status, thread.code, thread.message)
283
+ return
284
+ }
285
+ history = thread.history
286
+ }
287
+
288
+ if (dailyLimit > 0 && (await store.countSince(caller, startOfUtcDay())) >= dailyLimit) {
289
+ fail(res, 429, 'rate_limited', 'Too many messages from this agent today.', { 'retry-after': '3600' })
290
+ return
291
+ }
292
+
293
+ const msg = {
294
+ id: newMessageId(),
295
+ caller_id: caller,
296
+ account,
297
+ client_id: client,
298
+ idempotency_key: key,
299
+ previous_id: previousId,
300
+ message: text,
301
+ context: body.context || {},
302
+ status: 'working',
303
+ reply: null,
304
+ error: null,
305
+ created_at: new Date().toISOString(),
306
+ finished_at: null,
307
+ }
308
+ const saved = await store.insert(msg)
309
+ if (!saved.created) {
310
+ await respond(res, saved.message, origin)
311
+ return
312
+ }
313
+
314
+ const work = Promise.resolve()
315
+ .then(() => answer({ message: msg.message, context: msg.context, history, account: account || null, scope, caller }))
316
+ .catch(() => ({ error: { code: 'failed', message: 'The agent could not answer.' } }))
317
+ .then(async (value) => {
318
+ const held = value?.confirm && !value.error ? await hold(value, msg, scope, origin) : null
319
+ const result = held ? held.result : outcome(value)
320
+ const confirmationId = held?.id || null
321
+ const finishedAt = new Date().toISOString()
322
+ await store.finish(msg.id, { ...result, finishedAt, confirmationId })
323
+ Object.assign(msg, result, { finished_at: finishedAt, confirmation_id: confirmationId })
324
+ })
325
+ let timer
326
+ const waited = new Promise((resolve) => { timer = setTimeout(resolve, waitMs) })
327
+ await Promise.race([work, waited])
328
+ clearTimeout(timer)
329
+ await respond(res, msg, origin)
330
+ }
331
+
332
+ async function readConfirmation(req, res, caller, id, origin) {
333
+ const access = await grantFor(req, caller)
334
+ if (!access.grant && access.status) {
335
+ fail(res, access.status, access.code, access.message)
336
+ return
337
+ }
338
+ const confirmation = oauth && access.grant ? await store.confirmationById(id) : null
339
+ const visible = confirmation
340
+ && confirmation.caller_id === caller
341
+ && confirmation.account === access.grant.account
342
+ && confirmation.client_id === access.grant.client_id
343
+ if (!visible) {
344
+ fail(res, 404, 'not_found', 'Confirmation not found.')
345
+ return
346
+ }
347
+ const body = publicConfirmation(confirmation, oauth.confirmUrl(origin, confirmation.id))
348
+ const waiting = body.status === 'pending' || body.status === 'working'
349
+ send(res, 200, body, waiting ? { headers: { 'retry-after': String(retryAfter) } } : {})
350
+ }
351
+
352
+ async function readMessage(req, res, caller, id, origin) {
353
+ const access = await grantFor(req, caller)
354
+ if (!access.grant && access.status) {
355
+ fail(res, access.status, access.code, access.message)
356
+ return
357
+ }
358
+ const msg = await store.byId(id)
359
+ const visible = msg
360
+ && msg.caller_id === caller
361
+ && msg.account === (access.grant?.account || '')
362
+ && msg.client_id === (access.grant?.client_id || '')
363
+ if (!visible || expired(msg)) {
364
+ fail(res, 404, 'not_found', 'Message not found.')
365
+ return
366
+ }
367
+ if (msg.status === 'working') {
368
+ send(res, 200, publicMessage(msg), { headers: { 'retry-after': String(retryAfter) } })
369
+ return
370
+ }
371
+ send(res, 200, await view(msg, origin))
372
+ }
373
+
374
+ return { server, handle: onRequest }
375
+ }