@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/store.mjs ADDED
@@ -0,0 +1,358 @@
1
+ import { randomBytes, createHash } from 'node:crypto'
2
+ import { mkdirSync } from 'node:fs'
3
+ import { dirname } from 'node:path'
4
+
5
+ const SCHEMA = `
6
+ CREATE TABLE IF NOT EXISTS messages (
7
+ id TEXT PRIMARY KEY,
8
+ caller_id TEXT NOT NULL,
9
+ account TEXT NOT NULL DEFAULT '',
10
+ client_id TEXT NOT NULL DEFAULT '',
11
+ idempotency_key TEXT,
12
+ previous_id TEXT,
13
+ message TEXT NOT NULL,
14
+ context_json TEXT NOT NULL,
15
+ status TEXT NOT NULL,
16
+ reply TEXT,
17
+ error_json TEXT,
18
+ created_at TEXT NOT NULL,
19
+ finished_at TEXT
20
+ );
21
+ CREATE INDEX IF NOT EXISTS messages_by_caller ON messages (caller_id, created_at);
22
+ CREATE UNIQUE INDEX IF NOT EXISTS messages_idempotency
23
+ ON messages (caller_id, account, client_id, idempotency_key)
24
+ WHERE idempotency_key IS NOT NULL;
25
+ CREATE TABLE IF NOT EXISTS grants (
26
+ token_hash TEXT PRIMARY KEY,
27
+ account TEXT NOT NULL,
28
+ client_id TEXT NOT NULL
29
+ );
30
+ CREATE TABLE IF NOT EXISTS oauth_codes (
31
+ code_hash TEXT PRIMARY KEY,
32
+ account TEXT NOT NULL,
33
+ oauth_client TEXT NOT NULL,
34
+ cap_from TEXT NOT NULL,
35
+ redirect_uri TEXT NOT NULL,
36
+ code_challenge TEXT NOT NULL,
37
+ expires_at TEXT NOT NULL
38
+ );
39
+ CREATE TABLE IF NOT EXISTS oauth_devices (
40
+ device_hash TEXT PRIMARY KEY,
41
+ user_code TEXT NOT NULL UNIQUE,
42
+ oauth_client TEXT NOT NULL,
43
+ cap_from TEXT NOT NULL,
44
+ client_name TEXT NOT NULL,
45
+ verified_host TEXT NOT NULL DEFAULT '',
46
+ status TEXT NOT NULL,
47
+ account TEXT NOT NULL DEFAULT '',
48
+ last_poll_at TEXT NOT NULL DEFAULT '',
49
+ expires_at TEXT NOT NULL
50
+ );
51
+ CREATE TABLE IF NOT EXISTS confirmations (
52
+ id TEXT PRIMARY KEY,
53
+ caller_id TEXT NOT NULL,
54
+ account TEXT NOT NULL,
55
+ client_id TEXT NOT NULL,
56
+ message_id TEXT NOT NULL,
57
+ summary TEXT NOT NULL,
58
+ details_json TEXT NOT NULL,
59
+ status TEXT NOT NULL,
60
+ result_json TEXT,
61
+ expires_at TEXT NOT NULL,
62
+ created_at TEXT NOT NULL,
63
+ finished_at TEXT
64
+ );
65
+ `
66
+
67
+ // Columns added after the first release. Older databases get them on migrate.
68
+ const GRANT_COLUMNS = [
69
+ "ALTER TABLE grants ADD COLUMN kind TEXT NOT NULL DEFAULT 'access'",
70
+ "ALTER TABLE grants ADD COLUMN oauth_client TEXT NOT NULL DEFAULT ''",
71
+ "ALTER TABLE grants ADD COLUMN expires_at TEXT NOT NULL DEFAULT ''",
72
+ "ALTER TABLE grants ADD COLUMN grant_id TEXT NOT NULL DEFAULT ''",
73
+ "ALTER TABLE grants ADD COLUMN scope TEXT NOT NULL DEFAULT 'read'",
74
+ "ALTER TABLE oauth_codes ADD COLUMN scope TEXT NOT NULL DEFAULT 'read'",
75
+ "ALTER TABLE oauth_devices ADD COLUMN scope TEXT NOT NULL DEFAULT 'read'",
76
+ 'ALTER TABLE messages ADD COLUMN confirmation_id TEXT',
77
+ ]
78
+
79
+ export function tokenHash(token) {
80
+ return createHash('sha256').update(token).digest('hex')
81
+ }
82
+
83
+ export function newMessageId() {
84
+ return `msg_${randomBytes(8).toString('hex')}`
85
+ }
86
+
87
+ function isUniqueViolation(err) {
88
+ const text = `${err.code || ''} ${err.message || ''}`
89
+ return text.includes('UNIQUE') || err.code === '23505' || err.errcode === 2067
90
+ }
91
+
92
+ function mapMessage(row) {
93
+ if (!row) return null
94
+ return {
95
+ id: row.id,
96
+ caller_id: row.caller_id,
97
+ account: row.account || '',
98
+ client_id: row.client_id || '',
99
+ idempotency_key: row.idempotency_key,
100
+ previous_id: row.previous_id || null,
101
+ message: row.message,
102
+ context: JSON.parse(row.context_json || '{}'),
103
+ status: row.status,
104
+ reply: row.reply ?? null,
105
+ error: row.error_json ? JSON.parse(row.error_json) : null,
106
+ created_at: row.created_at,
107
+ finished_at: row.finished_at ?? null,
108
+ confirmation_id: row.confirmation_id || null,
109
+ }
110
+ }
111
+
112
+ function mapConfirmation(row) {
113
+ if (!row) return null
114
+ return {
115
+ id: row.id,
116
+ caller_id: row.caller_id,
117
+ account: row.account,
118
+ client_id: row.client_id,
119
+ message_id: row.message_id,
120
+ summary: row.summary,
121
+ authorization_details: JSON.parse(row.details_json || '[]'),
122
+ status: row.status,
123
+ result: row.result_json ? JSON.parse(row.result_json) : null,
124
+ expires_at: row.expires_at,
125
+ created_at: row.created_at,
126
+ finished_at: row.finished_at ?? null,
127
+ }
128
+ }
129
+
130
+ export function newConfirmationId() {
131
+ return `cfm_${randomBytes(18).toString('base64url')}`
132
+ }
133
+
134
+ function sqliteStore(db) {
135
+ const run = (sql, params = []) => db.prepare(sql).run(...params)
136
+ const get = (sql, params = []) => db.prepare(sql).get(...params)
137
+ return driver({ run, get, close: () => db.close() })
138
+ }
139
+
140
+ async function postgresStore(connectionString) {
141
+ let pg
142
+ try {
143
+ pg = await import('pg')
144
+ } catch {
145
+ throw new Error('Postgres needs the optional pg package. Install pg, then set DATABASE_URL.')
146
+ }
147
+ const pool = new pg.default.Pool({ connectionString })
148
+ const run = async (sql, params = []) => { await pool.query(toPg(sql), params) }
149
+ const get = async (sql, params = []) => (await pool.query(toPg(sql), params)).rows[0]
150
+ return driver({ run, get, close: () => pool.end() })
151
+ }
152
+
153
+ function toPg(sql) {
154
+ let n = 0
155
+ return sql.replace(/\?/g, () => `$${++n}`)
156
+ }
157
+
158
+ function driver({ run, get, close }) {
159
+ return {
160
+ close,
161
+ async migrate() {
162
+ for (const statement of SCHEMA.split(';')) {
163
+ const sql = statement.trim()
164
+ if (sql) await run(sql)
165
+ }
166
+ // Databases created before follow-ups existed lack this column.
167
+ for (const sql of ['ALTER TABLE messages ADD COLUMN previous_id TEXT', ...GRANT_COLUMNS]) {
168
+ try {
169
+ await run(sql)
170
+ } catch {
171
+ // Already there.
172
+ }
173
+ }
174
+ },
175
+ /**
176
+ * `clientId` is the CAP-From id the token is bound to. `oauthClient` is the
177
+ * client id URL that logged in, empty for tokens set in cap.json. Tokens from
178
+ * one login share a `grantId`, so revoking one revokes the rest. `scope` is
179
+ * `read` or `write`.
180
+ */
181
+ async upsertGrant({ token, account, clientId, kind = 'access', oauthClient = '', expiresAt = '', grantId = '', scope = 'read' }) {
182
+ await run(
183
+ `INSERT INTO grants (token_hash, account, client_id, kind, oauth_client, expires_at, grant_id, scope) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
184
+ ON CONFLICT (token_hash) DO UPDATE SET account = excluded.account, client_id = excluded.client_id,
185
+ kind = excluded.kind, oauth_client = excluded.oauth_client, expires_at = excluded.expires_at,
186
+ grant_id = excluded.grant_id, scope = excluded.scope`,
187
+ [tokenHash(token), account, clientId, kind, oauthClient, expiresAt, grantId, scope],
188
+ )
189
+ },
190
+ async revokeGrant(token) {
191
+ await run('DELETE FROM grants WHERE token_hash = ?', [tokenHash(token)])
192
+ },
193
+ async revokeGrantId(grantId) {
194
+ if (grantId) await run('DELETE FROM grants WHERE grant_id = ?', [grantId])
195
+ },
196
+ async grantByToken(token) {
197
+ if (!token) return null
198
+ const row = await get(
199
+ 'SELECT token_hash, account, client_id, kind, oauth_client, expires_at, grant_id, scope FROM grants WHERE token_hash = ?',
200
+ [tokenHash(token)],
201
+ )
202
+ if (!row) return null
203
+ if (row.expires_at && Date.parse(row.expires_at) <= Date.now()) return null
204
+ return row
205
+ },
206
+ async insertCode({ code, account, oauthClient, capFrom, redirectUri, codeChallenge, expiresAt, scope = 'read' }) {
207
+ await run(
208
+ `INSERT INTO oauth_codes (code_hash, account, oauth_client, cap_from, redirect_uri, code_challenge, expires_at, scope)
209
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
210
+ [tokenHash(code), account, oauthClient, capFrom, redirectUri, codeChallenge, expiresAt, scope],
211
+ )
212
+ },
213
+ // A code works once: it is deleted as it is read.
214
+ async takeCode(code) {
215
+ if (!code) return null
216
+ const row = await get('DELETE FROM oauth_codes WHERE code_hash = ? RETURNING *', [tokenHash(code)])
217
+ if (!row || Date.parse(row.expires_at) <= Date.now()) return null
218
+ return row
219
+ },
220
+ // `scope` is what the caller asked for until the person decides, then what they granted.
221
+ async insertDevice({ deviceCode, userCode, oauthClient, capFrom, clientName, verifiedHost, expiresAt, scope = 'read' }) {
222
+ await run(
223
+ `INSERT INTO oauth_devices (device_hash, user_code, oauth_client, cap_from, client_name, verified_host, status, expires_at, scope)
224
+ VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?)`,
225
+ [tokenHash(deviceCode), userCode, oauthClient, capFrom, clientName, verifiedHost, expiresAt, scope],
226
+ )
227
+ },
228
+ async deviceByUserCode(userCode) {
229
+ const row = await get('SELECT * FROM oauth_devices WHERE user_code = ?', [userCode])
230
+ if (!row || Date.parse(row.expires_at) <= Date.now()) return null
231
+ return row
232
+ },
233
+ async deviceByCode(deviceCode) {
234
+ if (!deviceCode) return null
235
+ return (await get('SELECT * FROM oauth_devices WHERE device_hash = ?', [tokenHash(deviceCode)])) || null
236
+ },
237
+ // Only a pending login can be decided, and only once.
238
+ async decideDevice(userCode, { status, account = '', expiresAt = null, scope = null }) {
239
+ const row = await get(
240
+ `UPDATE oauth_devices SET status = ?, account = ?, expires_at = COALESCE(?, expires_at), scope = COALESCE(?, scope)
241
+ WHERE user_code = ? AND status = 'pending' RETURNING *`,
242
+ [status, account, expiresAt, scope, userCode],
243
+ )
244
+ return row || null
245
+ },
246
+ async touchDevice(deviceCode, at) {
247
+ await run('UPDATE oauth_devices SET last_poll_at = ? WHERE device_hash = ?', [at, tokenHash(deviceCode)])
248
+ },
249
+ // An approved login yields a token once: it is deleted as it is read.
250
+ async takeApprovedDevice(deviceCode) {
251
+ return (await get(
252
+ `DELETE FROM oauth_devices WHERE device_hash = ? AND status = 'approved' RETURNING *`,
253
+ [tokenHash(deviceCode)],
254
+ )) || null
255
+ },
256
+ async countSince(callerId, sinceIso) {
257
+ const row = await get(
258
+ 'SELECT COUNT(*) AS n FROM messages WHERE caller_id = ? AND created_at >= ?',
259
+ [callerId, sinceIso],
260
+ )
261
+ return Number(row?.n || 0)
262
+ },
263
+ async byIdempotency({ callerId, account, clientId, key }) {
264
+ return mapMessage(await get(
265
+ `SELECT * FROM messages
266
+ WHERE caller_id = ? AND account = ? AND client_id = ? AND idempotency_key = ?`,
267
+ [callerId, account, clientId, key],
268
+ ))
269
+ },
270
+ async insert(msg) {
271
+ try {
272
+ await run(
273
+ `INSERT INTO messages (
274
+ id, caller_id, account, client_id, idempotency_key, previous_id,
275
+ message, context_json, status, reply, error_json, created_at, finished_at
276
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
277
+ [
278
+ msg.id,
279
+ msg.caller_id,
280
+ msg.account || '',
281
+ msg.client_id || '',
282
+ msg.idempotency_key || null,
283
+ msg.previous_id || null,
284
+ msg.message,
285
+ JSON.stringify(msg.context || {}),
286
+ msg.status,
287
+ msg.reply ?? null,
288
+ msg.error ? JSON.stringify(msg.error) : null,
289
+ msg.created_at,
290
+ msg.finished_at || null,
291
+ ],
292
+ )
293
+ return { message: msg, created: true }
294
+ } catch (err) {
295
+ if (!isUniqueViolation(err) || !msg.idempotency_key) throw err
296
+ const existing = await this.byIdempotency({
297
+ callerId: msg.caller_id,
298
+ account: msg.account || '',
299
+ clientId: msg.client_id || '',
300
+ key: msg.idempotency_key,
301
+ })
302
+ return { message: existing, created: false }
303
+ }
304
+ },
305
+ async finish(id, { status, reply, error, finishedAt, confirmationId = null }) {
306
+ await run(
307
+ 'UPDATE messages SET status = ?, reply = ?, error_json = ?, finished_at = ?, confirmation_id = ? WHERE id = ?',
308
+ [status, reply ?? null, error ? JSON.stringify(error) : null, finishedAt, confirmationId, id],
309
+ )
310
+ },
311
+ async byId(id) {
312
+ return mapMessage(await get('SELECT * FROM messages WHERE id = ?', [id]))
313
+ },
314
+ async insertConfirmation(c) {
315
+ await run(
316
+ `INSERT INTO confirmations (id, caller_id, account, client_id, message_id, summary, details_json, status, expires_at, created_at)
317
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`,
318
+ [c.id, c.caller_id, c.account, c.client_id, c.message_id, c.summary, JSON.stringify(c.authorization_details || []), c.expires_at, c.created_at],
319
+ )
320
+ },
321
+ async confirmationById(id) {
322
+ return mapConfirmation(await get('SELECT * FROM confirmations WHERE id = ?', [id]))
323
+ },
324
+ // Only a pending, unexpired request can be decided, and only once.
325
+ async claimConfirmation(id, status) {
326
+ return mapConfirmation(await get(
327
+ `UPDATE confirmations SET status = ? WHERE id = ? AND status = 'pending' AND expires_at > ? RETURNING *`,
328
+ [status, id, new Date().toISOString()],
329
+ ))
330
+ },
331
+ async finishConfirmation(id, { status, result = null }) {
332
+ await run(
333
+ 'UPDATE confirmations SET status = ?, result_json = ?, finished_at = ? WHERE id = ?',
334
+ [status, result ? JSON.stringify(result) : null, new Date().toISOString(), id],
335
+ )
336
+ },
337
+ }
338
+ }
339
+
340
+ export async function createStore({ databaseUrl, sqlitePath }) {
341
+ if (databaseUrl && /^postgres(ql)?:\/\//.test(databaseUrl)) {
342
+ const store = await postgresStore(databaseUrl)
343
+ await store.migrate()
344
+ return store
345
+ }
346
+ const { DatabaseSync } = await import('node:sqlite')
347
+ const path = sqlitePath || ':memory:'
348
+ if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true })
349
+ const db = new DatabaseSync(path)
350
+ db.exec('PRAGMA journal_mode = WAL')
351
+ const store = sqliteStore(db)
352
+ await store.migrate()
353
+ return store
354
+ }
355
+
356
+ export function startOfUtcDay(date = new Date()) {
357
+ return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())).toISOString()
358
+ }
package/src/web.mjs ADDED
@@ -0,0 +1,56 @@
1
+ import { Readable } from 'node:stream'
2
+
3
+ /**
4
+ * Run the Node request handler for one Fetch API request.
5
+ * The body stays paused until the handler reads it.
6
+ */
7
+ export function handleWeb(handle, request) {
8
+ const url = new URL(request.url)
9
+ const headers = {}
10
+ request.headers.forEach((value, key) => {
11
+ headers[key] = value
12
+ })
13
+ const req = Readable.from(bodyChunks(request))
14
+ req.method = request.method
15
+ req.url = `${url.pathname}${url.search}`
16
+ req.headers = headers
17
+
18
+ const chunks = []
19
+ let status = 200
20
+ let responseHeaders = {}
21
+ const res = {
22
+ headersSent: false,
23
+ writeHead(code, next) {
24
+ status = code
25
+ responseHeaders = next || {}
26
+ this.headersSent = true
27
+ },
28
+ end(body) {
29
+ if (body != null && body !== '') chunks.push(Buffer.isBuffer(body) ? body : Buffer.from(String(body)))
30
+ this.headersSent = true
31
+ },
32
+ }
33
+
34
+ return handle(req, res).then(() => {
35
+ const webHeaders = new Headers()
36
+ for (const [key, value] of Object.entries(responseHeaders)) {
37
+ if (value != null) webHeaders.set(key, String(value))
38
+ }
39
+ const raw = Buffer.concat(chunks)
40
+ return new Response(status === 204 ? null : raw, { status, headers: webHeaders })
41
+ })
42
+ }
43
+
44
+ async function* bodyChunks(request) {
45
+ if (!request.body) return
46
+ const reader = request.body.getReader()
47
+ try {
48
+ while (true) {
49
+ const { done, value } = await reader.read()
50
+ if (done) return
51
+ yield value
52
+ }
53
+ } finally {
54
+ reader.releaseLock()
55
+ }
56
+ }