@conciergeprotocol/server 0.0.0-stage → 0.3.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/src/index.js ADDED
@@ -0,0 +1,137 @@
1
+ import { join } from 'node:path'
2
+ import { createOAuth } from './oauth.mjs'
3
+ import { createCapServer as createNodeServer } from './node-server.mjs'
4
+ import { createStore } from './store.mjs'
5
+ import { handleWeb } from './web.mjs'
6
+
7
+ export { checkDoor } from './check.mjs'
8
+
9
+ /**
10
+ * Run a CAP front door. `answer` is the only part that belongs to the company.
11
+ * It receives `{ message, context, history, account, scope, caller }` and
12
+ * returns a reply string, `{ reply }`, or `{ error: { code, message } }`.
13
+ */
14
+ export async function createCapServer(options = {}) {
15
+ const agent = normalizeAgent(options.agent)
16
+ if (typeof options.answer !== 'function') throw new Error('createCapServer needs an answer function.')
17
+
18
+ const sqlitePath = options.sqlitePath ?? process.env.SQLITE_PATH ?? join(process.cwd(), 'data', 'cap.sqlite')
19
+ const databaseUrl = options.databaseUrl != null
20
+ ? options.databaseUrl
21
+ : (options.sqlitePath ? '' : (process.env.DATABASE_URL || ''))
22
+ const store = await createStore({
23
+ databaseUrl,
24
+ sqlitePath: databaseUrl ? undefined : sqlitePath,
25
+ })
26
+
27
+ for (const grant of options.grants || []) {
28
+ if (grant.token && grant.account && grant.client) {
29
+ await store.upsertGrant({
30
+ token: grant.token,
31
+ account: grant.account,
32
+ clientId: grant.client,
33
+ scope: grant.scope === 'write' ? 'write' : 'read',
34
+ })
35
+ }
36
+ }
37
+
38
+ const oauth = options.login || options.currentAccount
39
+ ? createOAuth({
40
+ store,
41
+ agentName: agent.name,
42
+ login: options.login,
43
+ currentAccount: options.currentAccount || null,
44
+ write: Boolean(options.write),
45
+ act: options.act || null,
46
+ accessTokenSeconds: options.accessTokenSeconds ?? 3600,
47
+ writeAccessTokenSeconds: options.writeAccessTokenSeconds ?? 900,
48
+ allowHttpClients: Boolean(options.allowHttpClients),
49
+ hint: options.hint || '',
50
+ fetchImpl: options.fetch,
51
+ deviceInterval: options.deviceInterval ?? 5,
52
+ })
53
+ : null
54
+
55
+ const node = createNodeServer({
56
+ store,
57
+ agent,
58
+ answer: normalizeAnswer(options.answer),
59
+ waitMs: options.waitMs ?? (options.waitSeconds ?? 20) * 1000,
60
+ retryAfter: options.retryAfter ?? 5,
61
+ retentionHours: options.retentionHours ?? 24,
62
+ dailyLimit: options.dailyLimit ?? 0,
63
+ maxTurns: options.maxTurns ?? 20,
64
+ oauth,
65
+ publicUrl: options.publicUrl ?? process.env.PUBLIC_URL ?? '',
66
+ extraRoutes: options.extraRoutes,
67
+ })
68
+
69
+ return {
70
+ handle: node.handle,
71
+ fetch(request) {
72
+ return handleWeb(node.handle, request)
73
+ },
74
+ accountFor: (req) => node.accountFor(nodeLike(req)),
75
+ requestApproval: (req, input) => node.requestApproval(nodeLike(req), input),
76
+ listen(port = Number(process.env.PORT || 8088), host = process.env.HOST || '0.0.0.0') {
77
+ return new Promise((resolve, reject) => {
78
+ node.server.once('error', reject)
79
+ node.server.listen(port, host, () => {
80
+ const address = node.server.address()
81
+ resolve(address)
82
+ })
83
+ })
84
+ },
85
+ async close() {
86
+ await new Promise((resolve) => node.server.close(resolve))
87
+ await store.close()
88
+ },
89
+ }
90
+ }
91
+
92
+ /** Same server, as a Fetch handler for Next.js and other Web servers. */
93
+ export async function createCapHandler(options) {
94
+ const cap = await createCapServer(options)
95
+ return {
96
+ handle: (request) => cap.fetch(request),
97
+ accountFor: cap.accountFor,
98
+ requestApproval: cap.requestApproval,
99
+ close: () => cap.close(),
100
+ }
101
+ }
102
+
103
+ /** A Fetch API Request reads like a Node request for header checks. */
104
+ function nodeLike(req) {
105
+ if (typeof req?.headers?.get !== 'function') return req
106
+ const headers = {}
107
+ req.headers.forEach((value, key) => {
108
+ headers[key] = value
109
+ })
110
+ try {
111
+ const url = new URL(req.url)
112
+ headers.host ||= url.host
113
+ headers['x-forwarded-proto'] ||= url.protocol.replace(':', '')
114
+ } catch {
115
+ // Without a URL the origin falls back to the Host header.
116
+ }
117
+ return { headers }
118
+ }
119
+
120
+ function normalizeAgent(agent) {
121
+ if (!agent?.name || typeof agent.name !== 'string') throw new Error('createCapServer needs agent.name.')
122
+ return {
123
+ name: agent.name,
124
+ status: agent.status || 'active',
125
+ chat: agent.chat || '',
126
+ spec: agent.spec || '',
127
+ oauth: agent.oauth || '',
128
+ }
129
+ }
130
+
131
+ function normalizeAnswer(answer) {
132
+ return async (input) => {
133
+ const value = await answer(input)
134
+ if (typeof value === 'string') return { reply: value }
135
+ return value
136
+ }
137
+ }
@@ -0,0 +1,414 @@
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
+ /**
172
+ * The account behind a request's bearer token, for the owner's own account
173
+ * routes. Null without a valid token for the agent named in CAP-From.
174
+ */
175
+ async function accountFor(req) {
176
+ const caller = callerId(req)
177
+ if (!caller || !req.headers.authorization) return null
178
+ const access = await grantFor(req, caller)
179
+ if (!access.grant) return null
180
+ return { account: access.grant.account, scope: access.grant.scope === 'write' ? 'write' : 'read', caller }
181
+ }
182
+
183
+ /** Holds an action from an owner's account route until the person approves it. */
184
+ async function requestApproval(req, { summary, authorization_details } = {}) {
185
+ const access = await accountFor(req)
186
+ if (!access) return { error: { code: 'login_required', message: 'Log in to make this change.' } }
187
+ if (access.scope !== 'write') {
188
+ return { error: { code: 'write_required', message: 'This login can read but not make changes. Log in again and allow changes.' } }
189
+ }
190
+ if (!oauth?.canConfirm) return { error: { code: 'failed', message: `${agent.name} cannot hold an action for approval.` } }
191
+ const text = String(summary || '').trim().slice(0, MAX_SUMMARY)
192
+ if (!text) return { error: { code: 'invalid_request', message: 'summary is required.' } }
193
+ const id = newConfirmationId()
194
+ const now = Date.now()
195
+ const expiresAt = new Date(now + CONFIRM_TTL_MS).toISOString()
196
+ await store.insertConfirmation({
197
+ id,
198
+ caller_id: access.caller,
199
+ account: access.account,
200
+ client_id: access.caller,
201
+ message_id: '',
202
+ summary: text,
203
+ authorization_details: Array.isArray(authorization_details) ? authorization_details : [],
204
+ expires_at: expiresAt,
205
+ created_at: new Date(now).toISOString(),
206
+ })
207
+ return { confirmation: { id, status: 'pending', url: oauth.confirmUrl(requestOrigin(req, publicUrl), id), summary: text, expires_at: expiresAt } }
208
+ }
209
+
210
+ async function route(req, res) {
211
+ const url = new URL(req.url || '/', 'http://localhost')
212
+ if (req.method === 'OPTIONS') {
213
+ send(res, 204, null, { raw: '' })
214
+ return
215
+ }
216
+ if (req.method === 'GET' && url.pathname === '/health') {
217
+ send(res, 200, { ok: true })
218
+ return
219
+ }
220
+ const origin = requestOrigin(req, publicUrl)
221
+ if (req.method === 'GET' && url.pathname === '/agent.txt') {
222
+ const oauthLine = agent.oauth || (oauth ? oauth.issuer(origin) : '')
223
+ const lines = [
224
+ 'Protocol: CAP/1.0',
225
+ `Name: ${agent.name}`,
226
+ agent.chat ? `Chat: ${agent.chat}` : '',
227
+ `CAP: ${origin}/cap/v1`,
228
+ oauthLine ? `OAuth: ${oauthLine}` : '',
229
+ `Spec: ${agent.spec || 'https://conciergeprotocol.org/cap.txt'}`,
230
+ ].filter(Boolean)
231
+ send(res, 200, null, { type: 'text/plain; charset=utf-8', raw: `${lines.join('\n')}\n` })
232
+ return
233
+ }
234
+ if (oauth && (await oauth.handle(req, res, url, origin))) return
235
+ if (extraRoutes && (await extraRoutes(req, res, url))) return
236
+
237
+ if (!url.pathname.startsWith('/cap/v1/')) {
238
+ fail(res, 404, 'not_found', 'Not found.')
239
+ return
240
+ }
241
+ if (req.headers['cap-version'] !== '1.0') {
242
+ fail(res, 400, 'version_not_supported', 'CAP-Version must be 1.0.')
243
+ return
244
+ }
245
+ const caller = callerId(req)
246
+ if (!caller) {
247
+ fail(res, 400, 'invalid_request', 'CAP-From must be agent:{id}.')
248
+ return
249
+ }
250
+
251
+ if (req.method === 'POST' && url.pathname === '/cap/v1/messages') {
252
+ await sendMessage(req, res, caller, origin)
253
+ return
254
+ }
255
+ const readMatch = url.pathname.match(/^\/cap\/v1\/messages\/([^/]+)$/)
256
+ if (req.method === 'GET' && readMatch) {
257
+ await readMessage(req, res, caller, decodeURIComponent(readMatch[1]), origin)
258
+ return
259
+ }
260
+ const confirmMatch = url.pathname.match(/^\/cap\/v1\/confirmations\/([^/]+)$/)
261
+ if (req.method === 'GET' && confirmMatch) {
262
+ await readConfirmation(req, res, caller, decodeURIComponent(confirmMatch[1]), origin)
263
+ return
264
+ }
265
+ fail(res, 404, 'not_found', 'Not found.')
266
+ }
267
+
268
+ async function sendMessage(req, res, caller, origin) {
269
+ if (agent.status && agent.status !== 'active') {
270
+ fail(res, 503, 'unavailable', `${agent.name} is not answering right now.`)
271
+ return
272
+ }
273
+ const access = await grantFor(req, caller)
274
+ if (!access.grant && access.status) {
275
+ fail(res, access.status, access.code, access.message)
276
+ return
277
+ }
278
+
279
+ let body
280
+ try {
281
+ body = JSON.parse(await readBody(req) || '{}')
282
+ } catch {
283
+ fail(res, 400, 'invalid_request', 'Body must be JSON.')
284
+ return
285
+ }
286
+ const text = typeof body.message === 'string' ? body.message.trim() : ''
287
+ if (!text || text.length > MAX_MESSAGE) {
288
+ fail(res, 400, 'invalid_request', `message must be text, up to ${MAX_MESSAGE} characters.`)
289
+ return
290
+ }
291
+ if (body.context != null && (typeof body.context !== 'object' || Array.isArray(body.context))) {
292
+ fail(res, 400, 'invalid_request', 'context must be an object.')
293
+ return
294
+ }
295
+ const previousId = body.previous_message_id ?? null
296
+ if (previousId != null && (typeof previousId !== 'string' || !previousId)) {
297
+ fail(res, 400, 'invalid_request', 'previous_message_id must be a message id.')
298
+ return
299
+ }
300
+
301
+ const account = access.grant?.account || ''
302
+ const client = access.grant?.client_id || ''
303
+ const scope = access.grant ? (access.grant.scope === 'write' ? 'write' : 'read') : null
304
+ const key = String(req.headers['idempotency-key'] || '')
305
+ if (key && !/^[A-Za-z0-9_.:-]{1,128}$/.test(key)) {
306
+ fail(res, 400, 'invalid_request', 'Idempotency-Key is invalid.')
307
+ return
308
+ }
309
+ if (key) {
310
+ const existing = await store.byIdempotency({ callerId: caller, account, clientId: client, key })
311
+ if (existing) {
312
+ await respond(res, existing, origin)
313
+ return
314
+ }
315
+ }
316
+
317
+ let history = []
318
+ if (previousId) {
319
+ const thread = await threadFor(previousId, caller, account, client)
320
+ if (thread.status) {
321
+ fail(res, thread.status, thread.code, thread.message)
322
+ return
323
+ }
324
+ history = thread.history
325
+ }
326
+
327
+ if (dailyLimit > 0 && (await store.countSince(caller, startOfUtcDay())) >= dailyLimit) {
328
+ fail(res, 429, 'rate_limited', 'Too many messages from this agent today.', { 'retry-after': '3600' })
329
+ return
330
+ }
331
+
332
+ const msg = {
333
+ id: newMessageId(),
334
+ caller_id: caller,
335
+ account,
336
+ client_id: client,
337
+ idempotency_key: key,
338
+ previous_id: previousId,
339
+ message: text,
340
+ context: body.context || {},
341
+ status: 'working',
342
+ reply: null,
343
+ error: null,
344
+ created_at: new Date().toISOString(),
345
+ finished_at: null,
346
+ }
347
+ const saved = await store.insert(msg)
348
+ if (!saved.created) {
349
+ await respond(res, saved.message, origin)
350
+ return
351
+ }
352
+
353
+ const work = Promise.resolve()
354
+ .then(() => answer({ message: msg.message, context: msg.context, history, account: account || null, scope, caller }))
355
+ .catch(() => ({ error: { code: 'failed', message: 'The agent could not answer.' } }))
356
+ .then(async (value) => {
357
+ const held = value?.confirm && !value.error ? await hold(value, msg, scope, origin) : null
358
+ const result = held ? held.result : outcome(value)
359
+ const confirmationId = held?.id || null
360
+ const finishedAt = new Date().toISOString()
361
+ await store.finish(msg.id, { ...result, finishedAt, confirmationId })
362
+ Object.assign(msg, result, { finished_at: finishedAt, confirmation_id: confirmationId })
363
+ })
364
+ let timer
365
+ const waited = new Promise((resolve) => { timer = setTimeout(resolve, waitMs) })
366
+ await Promise.race([work, waited])
367
+ clearTimeout(timer)
368
+ await respond(res, msg, origin)
369
+ }
370
+
371
+ async function readConfirmation(req, res, caller, id, origin) {
372
+ const access = await grantFor(req, caller)
373
+ if (!access.grant && access.status) {
374
+ fail(res, access.status, access.code, access.message)
375
+ return
376
+ }
377
+ const confirmation = oauth && access.grant ? await store.confirmationById(id) : null
378
+ const visible = confirmation
379
+ && confirmation.caller_id === caller
380
+ && confirmation.account === access.grant.account
381
+ && confirmation.client_id === access.grant.client_id
382
+ if (!visible) {
383
+ fail(res, 404, 'not_found', 'Confirmation not found.')
384
+ return
385
+ }
386
+ const body = publicConfirmation(confirmation, oauth.confirmUrl(origin, confirmation.id))
387
+ const waiting = body.status === 'pending' || body.status === 'working'
388
+ send(res, 200, body, waiting ? { headers: { 'retry-after': String(retryAfter) } } : {})
389
+ }
390
+
391
+ async function readMessage(req, res, caller, id, origin) {
392
+ const access = await grantFor(req, caller)
393
+ if (!access.grant && access.status) {
394
+ fail(res, access.status, access.code, access.message)
395
+ return
396
+ }
397
+ const msg = await store.byId(id)
398
+ const visible = msg
399
+ && msg.caller_id === caller
400
+ && msg.account === (access.grant?.account || '')
401
+ && msg.client_id === (access.grant?.client_id || '')
402
+ if (!visible || expired(msg)) {
403
+ fail(res, 404, 'not_found', 'Message not found.')
404
+ return
405
+ }
406
+ if (msg.status === 'working') {
407
+ send(res, 200, publicMessage(msg), { headers: { 'retry-after': String(retryAfter) } })
408
+ return
409
+ }
410
+ send(res, 200, await view(msg, origin))
411
+ }
412
+
413
+ return { server, handle: onRequest, accountFor, requestApproval }
414
+ }