|
| 1 | +import { eq } from 'drizzle-orm' |
| 2 | +import type { Context } from 'hono' |
| 3 | + |
| 4 | +import { db, schema } from '../db/client.server.js' |
| 5 | +import { auth } from '../lib/auth.js' |
| 6 | +import { getLatestReadyVersion, loadVersionSchemas } from '../lib/version-helpers.server.js' |
| 7 | + |
| 8 | +const DEFAULT_SCHEMA_SLUG = 'update' |
| 9 | +const DEFAULT_SCHEMA = { |
| 10 | + type: 'object', |
| 11 | + properties: { |
| 12 | + title: { type: 'string' }, |
| 13 | + summary: { type: 'string' }, |
| 14 | + key_points: { type: 'array', items: { type: 'string' } }, |
| 15 | + source: { type: 'string' }, |
| 16 | + timestamp: { type: 'string' }, |
| 17 | + }, |
| 18 | + required: ['title', 'summary'], |
| 19 | + additionalProperties: false, |
| 20 | +} |
| 21 | + |
| 22 | +function escapeHtml(s: string): string { |
| 23 | + return s |
| 24 | + .replace(/&/g, '&') |
| 25 | + .replace(/</g, '<') |
| 26 | + .replace(/>/g, '>') |
| 27 | + .replace(/"/g, '"') |
| 28 | +} |
| 29 | + |
| 30 | +async function verifyAgentKey(key: string) { |
| 31 | + try { |
| 32 | + const result = await auth.api.verifyApiKey({ body: { key } }) |
| 33 | + if (!result?.valid || !result.key) return null |
| 34 | + const meta = (result.key as any).metadata as Record<string, any> | null |
| 35 | + if (!meta?.agentShare || !meta.collectionIds?.length) return null |
| 36 | + return { |
| 37 | + userId: (result.key as any).userId ?? (result.key as any).referenceId, |
| 38 | + collectionId: meta.collectionIds[0] as string, |
| 39 | + } |
| 40 | + } catch { |
| 41 | + return null |
| 42 | + } |
| 43 | +} |
| 44 | + |
| 45 | +// GET /agent/:token — HTML instruction page for AI agents |
| 46 | +export async function agentPage(c: Context) { |
| 47 | + const token = c.req.param('token')! |
| 48 | + const keyInfo = await verifyAgentKey(token) |
| 49 | + |
| 50 | + if (!keyInfo) { |
| 51 | + return c.html( |
| 52 | + `<!DOCTYPE html><html><head><title>Invalid token</title></head><body> |
| 53 | +<h1>Invalid or expired token</h1> |
| 54 | +<p>This agent link is no longer valid. Ask the collection owner for a new link.</p> |
| 55 | +</body></html>`, |
| 56 | + 404, |
| 57 | + ) |
| 58 | + } |
| 59 | + |
| 60 | + const [coll] = await db |
| 61 | + .select({ |
| 62 | + id: schema.collections.id, |
| 63 | + slug: schema.collections.slug, |
| 64 | + name: schema.collections.name, |
| 65 | + ownerSlug: schema.organization.slug, |
| 66 | + ownerName: schema.organization.name, |
| 67 | + }) |
| 68 | + .from(schema.collections) |
| 69 | + .innerJoin(schema.organization, eq(schema.collections.organizationId, schema.organization.id)) |
| 70 | + .where(eq(schema.collections.id, keyInfo.collectionId)) |
| 71 | + .limit(1) |
| 72 | + |
| 73 | + if (!coll) { |
| 74 | + return c.html('<h1>Collection not found</h1>', 404) |
| 75 | + } |
| 76 | + |
| 77 | + const latest = await getLatestReadyVersion(coll.id) |
| 78 | + const versionMeta = (latest?.metadata as Record<string, unknown>) ?? null |
| 79 | + const description = (versionMeta?.description as string) ?? '' |
| 80 | + |
| 81 | + let schemaEntries: { slug: string; schema: object }[] = [] |
| 82 | + if (latest) { |
| 83 | + const entries = await loadVersionSchemas(latest.id) |
| 84 | + schemaEntries = entries.map((e) => ({ slug: e.slug, schema: e.schema })) |
| 85 | + } |
| 86 | + |
| 87 | + const hasSchemas = schemaEntries.length > 0 |
| 88 | + |
| 89 | + let examples: { id: string; type: string; data: unknown }[] = [] |
| 90 | + if (latest) { |
| 91 | + const rows = await db |
| 92 | + .select({ |
| 93 | + recordId: schema.recordObjects.recordId, |
| 94 | + type: schema.recordObjects.type, |
| 95 | + data: schema.recordObjects.data, |
| 96 | + }) |
| 97 | + .from(schema.versionRecords) |
| 98 | + .innerJoin( |
| 99 | + schema.recordObjects, |
| 100 | + eq(schema.versionRecords.recordHash, schema.recordObjects.hash), |
| 101 | + ) |
| 102 | + .where(eq(schema.versionRecords.versionId, latest.id)) |
| 103 | + .limit(3) |
| 104 | + examples = rows.map((r) => ({ id: r.recordId, type: r.type, data: r.data })) |
| 105 | + } |
| 106 | + |
| 107 | + const origin = new URL(c.req.url).origin |
| 108 | + const collPath = `${coll.ownerSlug}/${coll.slug}` |
| 109 | + const negotiateUrl = `${origin}/api/collections/${collPath}/versions/negotiate` |
| 110 | + const aiTxtUrl = `${origin}/.well-known/ai.txt` |
| 111 | + |
| 112 | + const displaySchemas = hasSchemas |
| 113 | + ? schemaEntries |
| 114 | + : [{ slug: DEFAULT_SCHEMA_SLUG, schema: DEFAULT_SCHEMA }] |
| 115 | + const exampleType = displaySchemas[0]!.slug |
| 116 | + const exampleSchemaObj = displaySchemas[0]!.schema as Record<string, any> |
| 117 | + const exampleData: Record<string, unknown> = {} |
| 118 | + if (exampleSchemaObj.properties) { |
| 119 | + for (const [key, prop] of Object.entries(exampleSchemaObj.properties as Record<string, any>)) { |
| 120 | + if (prop.type === 'string') exampleData[key] = `your ${key} here` |
| 121 | + else if (prop.type === 'array') exampleData[key] = ['item 1', 'item 2'] |
| 122 | + else exampleData[key] = null |
| 123 | + } |
| 124 | + } |
| 125 | + |
| 126 | + const negotiateExample = JSON.stringify( |
| 127 | + { |
| 128 | + base_version: latest?.semver ?? null, |
| 129 | + message: 'Added update from conversation', |
| 130 | + schemas: Object.fromEntries(displaySchemas.map((s) => [s.slug, s.schema])), |
| 131 | + manifest: [{ id: 'record-1', type: exampleType, hash: '<sha256-of-canonical-json>' }], |
| 132 | + files: [], |
| 133 | + }, |
| 134 | + null, |
| 135 | + 2, |
| 136 | + ) |
| 137 | + |
| 138 | + const recordExample = JSON.stringify( |
| 139 | + { id: 'record-1', type: exampleType, data: exampleData }, |
| 140 | + null, |
| 141 | + 2, |
| 142 | + ) |
| 143 | + |
| 144 | + const html = `<!DOCTYPE html> |
| 145 | +<html lang="en"> |
| 146 | +<head> |
| 147 | +<meta charset="utf-8"> |
| 148 | +<meta name="viewport" content="width=device-width, initial-scale=1"> |
| 149 | +<title>Agent: ${escapeHtml(collPath)}</title> |
| 150 | +<style> |
| 151 | +body { font-family: system-ui, -apple-system, sans-serif; max-width: 760px; margin: 0 auto; padding: 2rem; line-height: 1.6; color: #1a1a1a; } |
| 152 | +h1 { font-size: 1.4rem; margin-bottom: 0.25rem; } |
| 153 | +h2 { margin-top: 2rem; font-size: 1.1rem; border-bottom: 1px solid #ddd; padding-bottom: 0.3rem; } |
| 154 | +h3 { margin-top: 1.5rem; font-size: 0.95rem; } |
| 155 | +pre { background: #f5f5f0; padding: 1rem; overflow-x: auto; border: 1px solid #e0e0d8; font-size: 0.8125rem; border-radius: 4px; } |
| 156 | +code { background: #f5f5f0; padding: 0.125rem 0.35rem; font-size: 0.8125rem; border-radius: 3px; } |
| 157 | +table.kv { border-collapse: collapse; width: 100%; margin: 1rem 0; } |
| 158 | +table.kv th { text-align: left; padding: 0.4rem 1rem 0.4rem 0; color: #666; font-weight: 500; font-size: 0.8125rem; white-space: nowrap; vertical-align: top; width: 140px; } |
| 159 | +table.kv td { padding: 0.4rem 0; font-size: 0.875rem; } |
| 160 | +table.kv tr { border-bottom: 1px solid #eee; } |
| 161 | +.subtitle { color: #666; font-size: 0.875rem; margin-top: 0; } |
| 162 | +.note { background: #f0f4ff; border: 1px solid #d0d8f0; border-radius: 4px; padding: 0.75rem 1rem; font-size: 0.8125rem; color: #333; margin: 1rem 0; } |
| 163 | +.recommend { background: #f0faf0; border: 1px solid #c0e0c0; border-radius: 4px; padding: 0.75rem 1rem; font-size: 0.8125rem; color: #1a3a1a; margin: 1rem 0; } |
| 164 | +a { color: #1a6dcc; } |
| 165 | +hr { border: none; border-top: 1px solid #ddd; margin: 2rem 0; } |
| 166 | +</style> |
| 167 | +</head> |
| 168 | +<body> |
| 169 | +
|
| 170 | +<h1>Agent Write Access</h1> |
| 171 | +<p class="subtitle">This page grants temporary write access to an Underlay collection. It is designed to be read by an AI agent. The token embedded in this URL is a valid API key — use it as a Bearer token to authenticate requests.</p> |
| 172 | +
|
| 173 | +<h2>Collection Details</h2> |
| 174 | +<table class="kv"> |
| 175 | +<tr><th>Collection</th><td><strong>${escapeHtml(collPath)}</strong></td></tr> |
| 176 | +<tr><th>Name</th><td>${escapeHtml(coll.name)}</td></tr> |
| 177 | +${description ? `<tr><th>Description</th><td>${escapeHtml(description)}</td></tr>` : ''} |
| 178 | +<tr><th>Current version</th><td>${latest ? escapeHtml(latest.semver) : 'None (empty collection)'}</td></tr> |
| 179 | +${latest ? `<tr><th>Records</th><td>${latest.recordCount.toLocaleString()}</td></tr>` : ''} |
| 180 | +${latest ? `<tr><th>Files</th><td>${latest.fileCount.toLocaleString()}</td></tr>` : ''} |
| 181 | +<tr><th>API key</th><td><code>${escapeHtml(token)}</code></td></tr> |
| 182 | +<tr><th>Collection URL</th><td><a href="${escapeHtml(origin)}/${escapeHtml(collPath)}">${escapeHtml(origin)}/${escapeHtml(collPath)}</a></td></tr> |
| 183 | +</table> |
| 184 | +
|
| 185 | +<h2>Schema</h2> |
| 186 | +${ |
| 187 | + hasSchemas |
| 188 | + ? schemaEntries |
| 189 | + .map( |
| 190 | + (s) => ` |
| 191 | +<h3>Type: <code>${escapeHtml(s.slug)}</code></h3> |
| 192 | +<pre>${escapeHtml(JSON.stringify(s.schema, null, 2))}</pre>`, |
| 193 | + ) |
| 194 | + .join('') |
| 195 | + : ` |
| 196 | +<p>This collection has <strong>no existing schema</strong>. You must provide a schema when pushing. The following default is recommended:</p> |
| 197 | +<div class="recommend"> |
| 198 | +<strong>Recommended default schema</strong> — type: <code>${escapeHtml(DEFAULT_SCHEMA_SLUG)}</code> |
| 199 | +<pre style="margin:0.5rem 0 0;background:transparent;border:none;padding:0;">${escapeHtml(JSON.stringify(DEFAULT_SCHEMA, null, 2))}</pre> |
| 200 | +<p style="margin:0.5rem 0 0;">Fields <code>title</code> and <code>summary</code> are required. The rest are optional.</p> |
| 201 | +</div>` |
| 202 | +} |
| 203 | +
|
| 204 | +${ |
| 205 | + examples.length > 0 |
| 206 | + ? ` |
| 207 | +<h3>Example records from the current version</h3> |
| 208 | +<pre>${escapeHtml(JSON.stringify(examples, null, 2))}</pre>` |
| 209 | + : `<p>This collection has no entries yet. Your update will be the first.</p>` |
| 210 | +} |
| 211 | +
|
| 212 | +<h2>How to Write Data</h2> |
| 213 | +<p>All writes use the <strong>negotiate protocol</strong> — a three-step flow (similar to git’s pack negotiation) that handles deduplication, schema validation, and version creation. Authenticate every request with the API key above.</p> |
| 214 | +
|
| 215 | +<div class="note"> |
| 216 | +<strong>Full protocol details</strong> including record hashing, canonicalization, file uploads, and privacy controls are documented in <a href="${escapeHtml(aiTxtUrl)}">${escapeHtml(aiTxtUrl)}</a>. Read that file for the complete reference. The summary below covers the essential steps. |
| 217 | +</div> |
| 218 | +
|
| 219 | +<h3>Step 1: Negotiate</h3> |
| 220 | +<p>POST a manifest of what the new version should contain. The server responds with which records it still needs.</p> |
| 221 | +<pre>POST ${escapeHtml(negotiateUrl)} |
| 222 | +Authorization: Bearer ${escapeHtml(token)} |
| 223 | +Content-Type: application/json</pre> |
| 224 | +
|
| 225 | +<h3>Request body</h3> |
| 226 | +<pre>${escapeHtml(negotiateExample)}</pre> |
| 227 | +
|
| 228 | +<table class="kv"> |
| 229 | +<tr><th>base_version</th><td>The semver of the version you’re building on (e.g. <code>${latest ? escapeHtml(latest.semver) : 'null'}</code>). Use <code>null</code> for the first push.</td></tr> |
| 230 | +<tr><th>schemas</th><td><strong>Required.</strong> A map of type name → JSON Schema for every type in this version.</td></tr> |
| 231 | +<tr><th>manifest</th><td><strong>Required.</strong> Array of <code>{id, type, hash}</code> for every record in the new version. The hash is SHA-256 of the canonical JSON (see ai.txt for the exact algorithm).</td></tr> |
| 232 | +<tr><th>files</th><td>Array of file hashes referenced by records. Empty array if none.</td></tr> |
| 233 | +<tr><th>message</th><td>Optional commit message describing this update.</td></tr> |
| 234 | +</table> |
| 235 | +
|
| 236 | +<h3>Response</h3> |
| 237 | +<pre>${escapeHtml(JSON.stringify({ session_id: '<uuid>', needed_records: ['<hash>'], needed_files: [], total_records: 1, already_have_records: 0 }, null, 2))}</pre> |
| 238 | +
|
| 239 | +<h3>Step 2: Send needed records</h3> |
| 240 | +<p>POST only the records the server asked for as NDJSON (one JSON object per line). Skip this step if <code>needed_records</code> was empty.</p> |
| 241 | +<pre>POST ${escapeHtml(origin)}/api/collections/${escapeHtml(collPath)}/versions/negotiate/<session_id>/records |
| 242 | +Authorization: Bearer ${escapeHtml(token)} |
| 243 | +Content-Type: application/x-ndjson</pre> |
| 244 | +
|
| 245 | +<h3>Record format</h3> |
| 246 | +<pre>${escapeHtml(recordExample)}</pre> |
| 247 | +
|
| 248 | +<h3>Step 3: Commit</h3> |
| 249 | +<p>Finalize the version. The server validates all records against schemas, computes the version hash, and creates the new immutable version.</p> |
| 250 | +<pre>POST ${escapeHtml(origin)}/api/collections/${escapeHtml(collPath)}/versions/negotiate/<session_id>/commit |
| 251 | +Authorization: Bearer ${escapeHtml(token)}</pre> |
| 252 | +
|
| 253 | +<h3>Response (201)</h3> |
| 254 | +<pre>${escapeHtml(JSON.stringify({ semver: 'v1.1.0', hash: '<sha256>', recordCount: 1, fileCount: 0 }, null, 2))}</pre> |
| 255 | +
|
| 256 | +<hr> |
| 257 | +
|
| 258 | +<h2>References</h2> |
| 259 | +<table class="kv"> |
| 260 | +<tr><th>AI integration guide</th><td><a href="${escapeHtml(aiTxtUrl)}">${escapeHtml(aiTxtUrl)}</a> — complete API reference including record hashing algorithm, canonicalization, file uploads, privacy, and error handling</td></tr> |
| 261 | +<tr><th>Documentation</th><td><a href="${escapeHtml(origin)}/docs">${escapeHtml(origin)}/docs</a></td></tr> |
| 262 | +<tr><th>Platform</th><td><a href="https://www.knowledgefutures.org">Knowledge Futures</a> (501c3)</td></tr> |
| 263 | +</table> |
| 264 | +
|
| 265 | +</body> |
| 266 | +</html>` |
| 267 | + |
| 268 | + return c.html(html) |
| 269 | +} |
0 commit comments