Skip to content

Commit 51719fc

Browse files
committed
First pass at agent link
1 parent 0d6f245 commit 51719fc

12 files changed

Lines changed: 394 additions & 12 deletions

File tree

server.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { ViteDevServer } from 'vite'
1313

1414
import _accounts from '~/api/accounts'
1515
import * as _admin from '~/api/admin'
16+
import * as _agentHandlers from '~/api/agent'
1617
import * as _ark from '~/api/ark'
1718
import { arkMiddleware } from '~/api/ark-middleware.server'
1819
import type { AuthEnv } from '~/api/auth.server'
@@ -53,6 +54,7 @@ function hot<T extends Record<string, any>>(staticMod: T, modulePath: string): T
5354
}
5455

5556
const admin = hot(_admin, '/src/api/admin.ts')
57+
const agentHandlers = hot(_agentHandlers, '/src/api/agent.ts')
5658
const ark = hot(_ark, '/src/api/ark.ts')
5759
const health = hot(_health, '/src/api/health.ts')
5860
const kfSummary = hot(_kfSummary, '/src/api/kf-summary.ts')
@@ -115,6 +117,9 @@ app.use('/api/admin/*', async (c, next) => {
115117
)
116118
})
117119

120+
// --- Agent share pages (token-authenticated, no session/API-key middleware) ---
121+
app.get('/agent/:token', agentHandlers.agentPage)
122+
118123
// --- ARK resolution middleware ---
119124
app.use('/ark\\:*', arkMiddleware)
120125

@@ -312,7 +317,7 @@ if (isProd) {
312317
devHttpServer = createHttpServer()
313318
const { createServer: createViteServer } = await import('vite')
314319
vite = await createViteServer({
315-
server: { middlewareMode: true, hmr: { server: devHttpServer, port: 24678 } },
320+
server: { middlewareMode: true, hmr: { server: devHttpServer, port: 24688 } },
316321
appType: 'custom',
317322
})
318323

src/api/agent.ts

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
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, '&amp;')
25+
.replace(/</g, '&lt;')
26+
.replace(/>/g, '&gt;')
27+
.replace(/"/g, '&quot;')
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 &mdash; 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> &mdash; 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> &mdash; a three-step flow (similar to git&rsquo;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&rsquo;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 &rarr; 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/&lt;session_id&gt;/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/&lt;session_id&gt;/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> &mdash; 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+
}

src/lib/auth.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,13 @@ export const auth = betterAuth({
6767
return `ul_${generateRandomString(length, 'a-z', 'A-Z')}`
6868
},
6969
enableMetadata: true,
70+
keyExpiration: {
71+
minExpiresIn: 0,
72+
},
7073
rateLimit: {
71-
enabled: false,
74+
enabled: true,
75+
timeWindow: 60 * 60,
76+
maxRequests: 1000,
7277
},
7378
permissions: {
7479
defaultPermissions: async (_referenceId, ctx) => {

src/routes/[owner]/[collection]/diff.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ export default function CollectionDiffPage() {
2222
const { data, versions } = useLoaderData() as { data: any; versions: any[] }
2323

2424
const isOwner =
25-
currentUser?.slug === owner || currentUser?.orgs?.some((o: any) => o.slug === owner)
25+
currentUser?.kfRole === 'admin' ||
26+
currentUser?.slug === owner ||
27+
currentUser?.orgs?.some((o: any) => o.slug === owner)
2628

2729
const [diff, setDiff] = useState<any>(null)
2830
const [diffError, setDiffError] = useState<string | null>(null)

0 commit comments

Comments
 (0)