lvwerra HF Staff commited on
Commit
53148eb
·
verified ·
1 Parent(s): 2679e4e

Upload folder using huggingface_hub

Browse files
client/app.css CHANGED
@@ -106,12 +106,23 @@ textarea { resize: vertical; }
106
  }
107
 
108
  /* agents panel */
109
- #agents-pop {
110
- position: fixed; z-index: 60; right: 14px; top: 58px; width: 320px;
111
  background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
112
  box-shadow: var(--shadow-md); padding: 14px 16px;
113
  }
114
- #agents-pop h3 { margin: 0 0 4px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.07em; color: var(--text-3); }
 
 
 
 
 
 
 
 
 
 
 
115
  #agents-pop .hint { font-size: 12.5px; color: var(--text-2); margin: 0 0 10px; }
116
  .agent-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; font-size: 13.5px; }
117
  .agent-row .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--border-strong); flex-shrink: 0; }
 
106
  }
107
 
108
  /* agents panel */
109
+ #agents-pop, #share-pop {
110
+ position: fixed; z-index: 60; right: 14px; top: 58px; width: 340px;
111
  background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
112
  box-shadow: var(--shadow-md); padding: 14px 16px;
113
  }
114
+ #share-pop { right: 90px; }
115
+ #agents-pop h3, #share-pop h3 { margin: 0 0 4px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.07em; color: var(--text-3); }
116
+ #agents-pop .hint, #share-pop .hint { font-size: 12.5px; color: var(--text-2); margin: 0 0 10px; }
117
+ #share-form { display: flex; gap: 6px; margin-top: 10px; }
118
+ .share-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; font-size: 13.5px; }
119
+ .share-row .iconbtn { margin-left: auto; }
120
+ .key-box {
121
+ margin-top: 10px; border: 1px solid var(--accent); background: var(--accent-soft);
122
+ border-radius: 8px; padding: 10px 12px; font-size: 12.5px;
123
+ }
124
+ .key-box code { font-family: var(--mono); font-size: 11.5px; word-break: break-all; display: block; margin: 6px 0; }
125
+ .key-box .row { margin-top: 6px; }
126
  #agents-pop .hint { font-size: 12.5px; color: var(--text-2); margin: 0 0 10px; }
127
  .agent-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; font-size: 13.5px; }
128
  .agent-row .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--border-strong); flex-shrink: 0; }
client/doc.html CHANGED
@@ -30,10 +30,21 @@
30
  <div id="header-tools"></div>
31
  <div class="spacer"></div>
32
  <div id="presence"></div>
 
33
  <button class="btn ghost small" id="agents-btn">Agents</button>
34
  <span id="whoami"></span>
35
  </header>
36
 
 
 
 
 
 
 
 
 
 
 
37
  <div id="agents-pop" class="hidden">
38
  <h3>Agents</h3>
39
  <p class="hint">Register a handle for your coding agent, copy its prompt, and mention it in any comment: <b>@handle do something</b>.</p>
 
30
  <div id="header-tools"></div>
31
  <div class="spacer"></div>
32
  <div id="presence"></div>
33
+ <button class="btn ghost small" id="share-btn">Share</button>
34
  <button class="btn ghost small" id="agents-btn">Agents</button>
35
  <span id="whoami"></span>
36
  </header>
37
 
38
+ <div id="share-pop" class="hidden">
39
+ <h3>Share</h3>
40
+ <p class="hint">People you share with can read, edit, comment — and point their own agents at this doc.</p>
41
+ <div id="share-list" class="muted">loading…</div>
42
+ <form id="share-form">
43
+ <input id="share-user" placeholder="Hugging Face username" />
44
+ <button class="btn small" type="submit">Share</button>
45
+ </form>
46
+ </div>
47
+
48
  <div id="agents-pop" class="hidden">
49
  <h3>Agents</h3>
50
  <p class="hint">Register a handle for your coding agent, copy its prompt, and mention it in any comment: <b>@handle do something</b>.</p>
client/src/common.js CHANGED
@@ -106,6 +106,32 @@ export function timeAgo(ts) {
106
  return `${Math.floor(s / 86400)}d`
107
  }
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  export function statusLabel(status) {
110
  return { pending: 'pending', claimed: 'seen', done: 'handled', failed: 'failed' }[status] || status
111
  }
 
106
  return `${Math.floor(s / 86400)}d`
107
  }
108
 
109
+ // One-time display of a freshly issued agent key, with copy actions.
110
+ export function agentKeyBox({ handle, key, docId }) {
111
+ const box = el('div', { class: 'key-box' })
112
+ box.appendChild(el('div', {}, `Key for @${handle} — copy it now, it won't be shown again:`))
113
+ box.appendChild(el('code', {}, key))
114
+ const row = el('div', { class: 'row' })
115
+ const copyKey = el('button', { class: 'btn small', type: 'button' }, 'Copy key')
116
+ copyKey.addEventListener('click', async () => {
117
+ await navigator.clipboard.writeText(key)
118
+ copyKey.textContent = 'Copied'
119
+ setTimeout(() => (copyKey.textContent = 'Copy key'), 1500)
120
+ })
121
+ const copyPrompt = el('button', { class: 'btn small primary', type: 'button' }, 'Copy agent prompt (incl. key)')
122
+ copyPrompt.addEventListener('click', async () => {
123
+ const text = await fetch(`/api/agent-prompt?handle=${handle}${docId ? `&doc=${docId}` : ''}`).then(r => r.text())
124
+ await navigator.clipboard.writeText(text.replace('<the key>', key))
125
+ copyPrompt.textContent = 'Copied'
126
+ setTimeout(() => (copyPrompt.textContent = 'Copy agent prompt (incl. key)'), 1500)
127
+ })
128
+ const dismiss = el('button', { class: 'btn small ghost', type: 'button' }, 'Done')
129
+ dismiss.addEventListener('click', () => box.remove())
130
+ row.append(copyKey, copyPrompt, dismiss)
131
+ box.appendChild(row)
132
+ return box
133
+ }
134
+
135
  export function statusLabel(status) {
136
  return { pending: 'pending', claimed: 'seen', done: 'handled', failed: 'failed' }[status] || status
137
  }
client/src/doc.js CHANGED
@@ -12,7 +12,7 @@ import { ySyncPluginKey, yUndoPluginKey, absolutePositionToRelativePosition, rel
12
  import { markdownToBlocks } from '../../server/md.js'
13
  import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
14
  import { Decoration, DecorationSet } from '@tiptap/pm/view'
15
- import { initAuth, api, el, icon, colorFor, timeAgo, statusLabel, renderMentionText } from './common.js'
16
  import { wordDiff, flattenMarkdown } from './diff.js'
17
 
18
  const docId = location.pathname.split('/').pop()
@@ -56,6 +56,7 @@ async function main() {
56
  wireComposer()
57
  wireSuggestComposer()
58
  wireAgentsPanel()
 
59
 
60
  document.getElementById('show-resolved').addEventListener('change', e => {
61
  state.showResolved = e.target.checked
@@ -84,13 +85,14 @@ async function main() {
84
 
85
  // collapse the active card when clicking outside cards / composers / menus
86
  document.addEventListener('mousedown', e => {
87
- if (e.target.closest('.card, .composer-box, #selection-menu, .mention-menu, #agents-pop, #agents-btn, #link-pop')) return
88
  if (state.activeItem) {
89
  state.activeItem = null
90
  renderMargin(editor)
91
  }
92
- const pop = document.getElementById('agents-pop')
93
- if (!pop.classList.contains('hidden')) pop.classList.add('hidden')
 
94
  })
95
 
96
  renderMargin(editor)
@@ -968,10 +970,66 @@ function wireAgentsPanel() {
968
  else {
969
  input.value = ''
970
  refreshAgents()
 
971
  }
972
  })
973
  }
974
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
975
  async function refreshAgents() {
976
  const res = await api('/api/agents')
977
  if (!res.agents) return
@@ -998,6 +1056,15 @@ async function refreshAgents() {
998
  setTimeout(() => copy.replaceChildren(icon('copy'), document.createTextNode('prompt')), 1500)
999
  })
1000
  row.appendChild(copy)
 
 
 
 
 
 
 
 
 
1001
  return row
1002
  })
1003
  )
 
12
  import { markdownToBlocks } from '../../server/md.js'
13
  import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
14
  import { Decoration, DecorationSet } from '@tiptap/pm/view'
15
+ import { initAuth, api, el, icon, colorFor, timeAgo, statusLabel, renderMentionText, agentKeyBox } from './common.js'
16
  import { wordDiff, flattenMarkdown } from './diff.js'
17
 
18
  const docId = location.pathname.split('/').pop()
 
56
  wireComposer()
57
  wireSuggestComposer()
58
  wireAgentsPanel()
59
+ wireSharePanel()
60
 
61
  document.getElementById('show-resolved').addEventListener('change', e => {
62
  state.showResolved = e.target.checked
 
85
 
86
  // collapse the active card when clicking outside cards / composers / menus
87
  document.addEventListener('mousedown', e => {
88
+ if (e.target.closest('.card, .composer-box, #selection-menu, .mention-menu, #agents-pop, #agents-btn, #share-pop, #share-btn, #link-pop')) return
89
  if (state.activeItem) {
90
  state.activeItem = null
91
  renderMargin(editor)
92
  }
93
+ for (const id of ['agents-pop', 'share-pop']) {
94
+ document.getElementById(id).classList.add('hidden')
95
+ }
96
  })
97
 
98
  renderMargin(editor)
 
970
  else {
971
  input.value = ''
972
  refreshAgents()
973
+ if (res.key) pop.appendChild(agentKeyBox({ handle: res.handle, key: res.key, docId }))
974
  }
975
  })
976
  }
977
 
978
+ // --- sharing ------------------------------------------------------------------
979
+
980
+ function wireSharePanel() {
981
+ const btn = document.getElementById('share-btn')
982
+ const pop = document.getElementById('share-pop')
983
+ btn.addEventListener('click', () => {
984
+ pop.classList.toggle('hidden')
985
+ if (!pop.classList.contains('hidden')) renderShareList()
986
+ })
987
+ document.getElementById('share-form').addEventListener('submit', async e => {
988
+ e.preventDefault()
989
+ const input = document.getElementById('share-user')
990
+ const username = input.value.trim()
991
+ if (!username) return
992
+ const res = await api(`/api/docs/${docId}/share`, { method: 'POST', body: { username } })
993
+ if (res.error) return alert(res.error)
994
+ input.value = ''
995
+ renderShareList()
996
+ })
997
+ }
998
+
999
+ async function renderShareList() {
1000
+ const meta = await api(`/api/docs/${docId}`)
1001
+ const wrap = document.getElementById('share-list')
1002
+ if (meta.error) {
1003
+ wrap.textContent = meta.error
1004
+ return
1005
+ }
1006
+ const isCreator = !meta.created_by || meta.created_by === state.me.username
1007
+ document.getElementById('share-form').classList.toggle('hidden', !isCreator)
1008
+ wrap.className = ''
1009
+ const rows = []
1010
+ if (meta.created_by) {
1011
+ const owner = el('div', { class: 'share-row' })
1012
+ owner.append(el('span', { class: 'handle' }, meta.created_by), el('span', { class: 'muted' }, 'owner'))
1013
+ rows.push(owner)
1014
+ }
1015
+ for (const username of meta.shared_with || []) {
1016
+ const row = el('div', { class: 'share-row' })
1017
+ row.appendChild(el('span', { class: 'handle' }, username))
1018
+ if (isCreator) {
1019
+ const rm = el('button', { class: 'iconbtn', title: 'Remove access' })
1020
+ rm.appendChild(icon('x'))
1021
+ rm.addEventListener('click', async () => {
1022
+ await api(`/api/docs/${docId}/share`, { method: 'POST', body: { username, remove: true } })
1023
+ renderShareList()
1024
+ })
1025
+ row.appendChild(rm)
1026
+ }
1027
+ rows.push(row)
1028
+ }
1029
+ if (!rows.length) rows.push(el('div', { class: 'muted' }, 'Not shared with anyone yet.'))
1030
+ wrap.replaceChildren(...rows)
1031
+ }
1032
+
1033
  async function refreshAgents() {
1034
  const res = await api('/api/agents')
1035
  if (!res.agents) return
 
1056
  setTimeout(() => copy.replaceChildren(icon('copy'), document.createTextNode('prompt')), 1500)
1057
  })
1058
  row.appendChild(copy)
1059
+ if (a.owner === state.me.username) {
1060
+ const rot = el('button', { class: 'btn small ghost', title: 'Issue a new key (the old one stops working)' }, 'new key')
1061
+ rot.addEventListener('click', async () => {
1062
+ const res = await api(`/api/agents/${a.handle}/rotate`, { method: 'POST' })
1063
+ if (res.error) alert(res.error)
1064
+ else document.getElementById('agents-pop').appendChild(agentKeyBox({ handle: a.handle, key: res.key, docId }))
1065
+ })
1066
+ row.appendChild(rot)
1067
+ }
1068
  return row
1069
  })
1070
  )
client/src/home.js CHANGED
@@ -1,12 +1,12 @@
1
- import { initAuth, api, el, icon, timeAgo } from './common.js'
2
 
3
  main()
4
 
5
  async function main() {
6
  const me = await initAuth('/')
7
  if (!me) return
8
- renderDocs()
9
- renderAgents()
10
 
11
  document.getElementById('new-doc').addEventListener('click', async () => {
12
  const title = prompt('Document title?', 'Untitled') || 'Untitled'
@@ -23,12 +23,13 @@ async function main() {
23
  if (res.error) alert(res.error)
24
  else {
25
  input.value = ''
26
- renderAgents()
 
27
  }
28
  })
29
  }
30
 
31
- async function renderDocs() {
32
  const res = await api('/api/docs')
33
  const wrap = document.getElementById('docs-list')
34
  if (!res.docs?.length) {
@@ -40,7 +41,8 @@ async function renderDocs() {
40
  ...res.docs.map(d => {
41
  const a = el('a', { class: 'doc-card', href: `/d/${d.id}` })
42
  a.appendChild(el('span', { class: 'title' }, d.title || 'Untitled'))
43
- a.appendChild(el('span', { class: 'meta' }, `updated ${timeAgo(d.updatedAt)} ago`))
 
44
  const del = el('button', { class: 'iconbtn', title: 'Delete document' })
45
  del.appendChild(icon('trash'))
46
  del.addEventListener('click', async e => {
@@ -57,7 +59,7 @@ async function renderDocs() {
57
  )
58
  }
59
 
60
- async function renderAgents() {
61
  const res = await api('/api/agents')
62
  const wrap = document.getElementById('agents-list')
63
  if (!res.agents?.length) {
@@ -71,6 +73,15 @@ async function renderAgents() {
71
  row.appendChild(el('span', { class: `dot${a.online ? ' on' : ''}` }))
72
  row.appendChild(el('span', { class: 'handle' }, `@${a.handle}`))
73
  row.appendChild(el('span', { class: 'muted' }, `${a.owner} · ${a.online ? 'online' : 'offline'}`))
 
 
 
 
 
 
 
 
 
74
  const copy = el('button', { class: 'btn small ghost copy' })
75
  copy.append(icon('copy'), document.createTextNode('prompt'))
76
  copy.addEventListener('click', async () => {
 
1
+ import { initAuth, api, el, icon, timeAgo, agentKeyBox } from './common.js'
2
 
3
  main()
4
 
5
  async function main() {
6
  const me = await initAuth('/')
7
  if (!me) return
8
+ renderDocs(me)
9
+ renderAgents(me)
10
 
11
  document.getElementById('new-doc').addEventListener('click', async () => {
12
  const title = prompt('Document title?', 'Untitled') || 'Untitled'
 
23
  if (res.error) alert(res.error)
24
  else {
25
  input.value = ''
26
+ renderAgents(me)
27
+ if (res.key) document.getElementById('agent-form').after(agentKeyBox({ handle: res.handle, key: res.key }))
28
  }
29
  })
30
  }
31
 
32
+ async function renderDocs(me) {
33
  const res = await api('/api/docs')
34
  const wrap = document.getElementById('docs-list')
35
  if (!res.docs?.length) {
 
41
  ...res.docs.map(d => {
42
  const a = el('a', { class: 'doc-card', href: `/d/${d.id}` })
43
  a.appendChild(el('span', { class: 'title' }, d.title || 'Untitled'))
44
+ const who = d.createdBy && d.createdBy !== me.username ? `by ${d.createdBy} · ` : ''
45
+ a.appendChild(el('span', { class: 'meta' }, `${who}updated ${timeAgo(d.updatedAt)} ago`))
46
  const del = el('button', { class: 'iconbtn', title: 'Delete document' })
47
  del.appendChild(icon('trash'))
48
  del.addEventListener('click', async e => {
 
59
  )
60
  }
61
 
62
+ async function renderAgents(me) {
63
  const res = await api('/api/agents')
64
  const wrap = document.getElementById('agents-list')
65
  if (!res.agents?.length) {
 
73
  row.appendChild(el('span', { class: `dot${a.online ? ' on' : ''}` }))
74
  row.appendChild(el('span', { class: 'handle' }, `@${a.handle}`))
75
  row.appendChild(el('span', { class: 'muted' }, `${a.owner} · ${a.online ? 'online' : 'offline'}`))
76
+ if (a.owner === me.username) {
77
+ const rot = el('button', { class: 'btn small ghost' }, 'new key')
78
+ rot.addEventListener('click', async () => {
79
+ const res = await api(`/api/agents/${a.handle}/rotate`, { method: 'POST' })
80
+ if (res.error) alert(res.error)
81
+ else row.after(agentKeyBox({ handle: a.handle, key: res.key }))
82
+ })
83
+ row.appendChild(rot)
84
+ }
85
  const copy = el('button', { class: 'btn small ghost copy' })
86
  copy.append(icon('copy'), document.createTextNode('prompt'))
87
  copy.addEventListener('click', async () => {
server/api.js CHANGED
@@ -1,9 +1,15 @@
1
  import express from 'express'
2
- import { requireUser, HOST, OAUTH_ENABLED } from './auth.js'
3
  import { HANDLE_RE, DOC_ID_RE } from './util.js'
4
  import * as store from './store.js'
5
  import * as collab from './collab.js'
6
 
 
 
 
 
 
 
7
  export function apiRouter() {
8
  const router = express.Router()
9
  router.use(express.json({ limit: '2mb' }))
@@ -13,21 +19,20 @@ export function apiRouter() {
13
  router.get('/docs', requireUser, (req, res) => {
14
  const reg = store.getRegistry()
15
  const docs = Object.entries(reg)
 
16
  .map(([id, meta]) => ({ id, ...meta }))
17
  .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
18
  res.json({ docs })
19
  })
20
 
21
- router.post('/docs', requireUser, async (req, res) => {
22
  const title = String(req.body?.title || 'Untitled').slice(0, 120)
23
  const id = await collab.createDoc(title, req.user.username)
24
  res.json({ id })
25
  })
26
 
27
- router.delete('/docs/:id', requireUser, checkDocId, async (req, res) => {
28
- const reg = store.getRegistry()
29
- const meta = reg[req.params.id]
30
- if (!meta) return res.status(404).json({ error: 'doc not found' })
31
  if (meta.createdBy && meta.createdBy !== req.user.username) {
32
  return res.status(403).json({ error: 'only the creator can delete a doc' })
33
  }
@@ -35,11 +40,36 @@ export function apiRouter() {
35
  res.json({ ok: true })
36
  })
37
 
38
- router.get('/docs/:id', requireUser, checkDocId, async (req, res) => {
39
- const reg = store.getRegistry()
40
- if (!reg[req.params.id]) return res.status(404).json({ error: 'doc not found' })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  const snapshot = await collab.getDocSnapshot(req.params.id)
42
- res.json({ id: req.params.id, title: reg[req.params.id].title, ...snapshot })
 
 
 
 
 
 
43
  })
44
 
45
  // --- image uploads ---
@@ -48,6 +78,7 @@ export function apiRouter() {
48
  '/docs/:id/upload',
49
  requireUser,
50
  checkDocId,
 
51
  express.raw({ type: 'image/*', limit: '10mb' }),
52
  (req, res) => {
53
  if (!Buffer.isBuffer(req.body) || !req.body.length) return res.status(400).json({ error: 'send the image bytes as the request body with an image/* content-type' })
@@ -59,7 +90,7 @@ export function apiRouter() {
59
 
60
  // --- comments / replies ---
61
 
62
- router.post('/docs/:id/threads/:tid/reply', requireUser, checkDocId, async (req, res) => {
63
  const { text, as_agent, mention_id } = req.body || {}
64
  if (!text || typeof text !== 'string') return res.status(400).json({ error: 'text required' })
65
  const identity = agentIdentity(req, as_agent)
@@ -78,7 +109,7 @@ export function apiRouter() {
78
 
79
  // --- suggestions ---
80
 
81
- router.post('/docs/:id/suggestions', requireUser, checkDocId, async (req, res) => {
82
  const { anchor_start, anchor_end, block_index, replacement_markdown, rationale, as_agent, mention_id, thread_id, supersedes } = req.body || {}
83
  if (typeof replacement_markdown !== 'string') return res.status(400).json({ error: 'replacement_markdown (string) required' })
84
  if (anchor_start == null && block_index == null && !supersedes) return res.status(400).json({ error: 'pass block_index or anchor_start/anchor_end (from GET /api/docs/:id blocks), or supersedes' })
@@ -102,7 +133,7 @@ export function apiRouter() {
102
  res.json({ ok: true, suggestion_id: result.suggestion.id })
103
  })
104
 
105
- router.post('/docs/:id/suggestions/:sid/:action', requireUser, checkDocId, async (req, res) => {
106
  if (!['accept', 'reject', 'reopen'].includes(req.params.action)) return res.status(404).json({ error: 'unknown action' })
107
  const result = await collab.resolveSuggestionAction(req.params.id, req.params.sid, req.params.action, req.user.username, {
108
  markOnly: req.body?.mark_only === true,
@@ -117,19 +148,35 @@ export function apiRouter() {
117
  res.json({ agents: collab.agentPresence() })
118
  })
119
 
120
- router.post('/agents', requireUser, (req, res) => {
121
  const handle = String(req.body?.handle || '').toLowerCase()
122
  if (!HANDLE_RE.test(handle)) return res.status(400).json({ error: 'handle must match ' + HANDLE_RE })
123
  const agents = store.getAgents()
124
  if (agents[handle] && agents[handle].owner !== req.user.username) {
125
  return res.status(409).json({ error: 'handle already registered by another user' })
126
  }
127
- agents[handle] = { owner: req.user.username, createdAt: agents[handle]?.createdAt || Date.now() }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  store.saveAgents(agents)
129
- res.json({ ok: true, handle })
130
  })
131
 
132
- router.delete('/agents/:handle', requireUser, (req, res) => {
133
  const agents = store.getAgents()
134
  const handle = req.params.handle
135
  if (!agents[handle]) return res.status(404).json({ error: 'not found' })
@@ -143,7 +190,7 @@ export function apiRouter() {
143
 
144
  router.get('/mentions', requireUser, async (req, res) => {
145
  const wait = Math.max(0, Math.min(Number(req.query.wait) || 0, 55))
146
- const result = await collab.pollMentions(req.user.username, wait)
147
  if (result.error) return res.status(400).json(result)
148
  res.json(result)
149
  })
@@ -170,8 +217,15 @@ function checkDocId(req, res, next) {
170
  next()
171
  }
172
 
173
- // Resolve who a write is attributed to. Agents must own the handle they act as.
 
174
  function agentIdentity(req, asAgent) {
 
 
 
 
 
 
175
  if (!asAgent) return { author: req.user.username, authorType: 'user' }
176
  const handle = String(asAgent).toLowerCase()
177
  const agents = store.getAgents()
@@ -182,16 +236,17 @@ function agentIdentity(req, asAgent) {
182
 
183
  function agentPrompt(docId, handle) {
184
  const tokenHint = OAUTH_ENABLED
185
- ? 'your Hugging Face token (usually already in $HF_TOKEN)'
186
- : `the dev token "dev:<your-username>" (no real HF token needed on this dev server)`
187
  return `You are the collaborative-editor agent "@${handle}" for the document ${docId} at ${HOST}.
188
 
189
  You participate in a shared document, but you NEVER edit the text directly. You only:
190
  1. reply to comment threads, and
191
  2. propose suggestions (block replacements) that a human can accept or reject.
192
 
193
- Authenticate every request with ${tokenHint}:
194
- AUTH='-H "Authorization: Bearer $HF_TOKEN"'
 
195
 
196
  Work loop — repeat until the user tells you to stop:
197
 
 
1
  import express from 'express'
2
+ import { requireUser, requireHuman, HOST, OAUTH_ENABLED } from './auth.js'
3
  import { HANDLE_RE, DOC_ID_RE } from './util.js'
4
  import * as store from './store.js'
5
  import * as collab from './collab.js'
6
 
7
+ function requireDocAccess(req, res, next) {
8
+ if (!store.getRegistry()[req.params.id]) return res.status(404).json({ error: 'doc not found' })
9
+ if (!store.canAccessDoc(req.params.id, req.user.username)) return res.status(404).json({ error: 'doc not found' })
10
+ next()
11
+ }
12
+
13
  export function apiRouter() {
14
  const router = express.Router()
15
  router.use(express.json({ limit: '2mb' }))
 
19
  router.get('/docs', requireUser, (req, res) => {
20
  const reg = store.getRegistry()
21
  const docs = Object.entries(reg)
22
+ .filter(([id]) => store.canAccessDoc(id, req.user.username))
23
  .map(([id, meta]) => ({ id, ...meta }))
24
  .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
25
  res.json({ docs })
26
  })
27
 
28
+ router.post('/docs', requireUser, requireHuman, async (req, res) => {
29
  const title = String(req.body?.title || 'Untitled').slice(0, 120)
30
  const id = await collab.createDoc(title, req.user.username)
31
  res.json({ id })
32
  })
33
 
34
+ router.delete('/docs/:id', requireUser, requireHuman, checkDocId, requireDocAccess, async (req, res) => {
35
+ const meta = store.getRegistry()[req.params.id]
 
 
36
  if (meta.createdBy && meta.createdBy !== req.user.username) {
37
  return res.status(403).json({ error: 'only the creator can delete a doc' })
38
  }
 
40
  res.json({ ok: true })
41
  })
42
 
43
+ // share / unshare (creator only)
44
+ router.post('/docs/:id/share', requireUser, requireHuman, checkDocId, requireDocAccess, async (req, res) => {
45
+ const meta = store.getRegistry()[req.params.id]
46
+ if (meta.createdBy && meta.createdBy !== req.user.username) {
47
+ return res.status(403).json({ error: 'only the creator can share a doc' })
48
+ }
49
+ const username = String(req.body?.username || '').trim()
50
+ const remove = req.body?.remove === true
51
+ if (!/^[a-zA-Z0-9_.-]{2,40}$/.test(username)) return res.status(400).json({ error: 'invalid username' })
52
+ if (username === req.user.username) return res.status(400).json({ error: 'that is you' })
53
+ if (!remove && OAUTH_ENABLED) {
54
+ const check = await fetch(`https://huggingface.co/api/users/${encodeURIComponent(username)}/overview`)
55
+ if (!check.ok) return res.status(400).json({ error: `no Hugging Face user named "${username}"` })
56
+ }
57
+ const current = meta.sharedWith || []
58
+ const sharedWith = remove ? current.filter(u => u !== username) : [...new Set([...current, username])]
59
+ store.upsertRegistry(req.params.id, { sharedWith })
60
+ res.json({ ok: true, shared_with: sharedWith })
61
+ })
62
+
63
+ router.get('/docs/:id', requireUser, checkDocId, requireDocAccess, async (req, res) => {
64
+ const meta = store.getRegistry()[req.params.id]
65
  const snapshot = await collab.getDocSnapshot(req.params.id)
66
+ res.json({
67
+ id: req.params.id,
68
+ title: meta.title,
69
+ created_by: meta.createdBy || null,
70
+ shared_with: meta.sharedWith || [],
71
+ ...snapshot,
72
+ })
73
  })
74
 
75
  // --- image uploads ---
 
78
  '/docs/:id/upload',
79
  requireUser,
80
  checkDocId,
81
+ requireDocAccess,
82
  express.raw({ type: 'image/*', limit: '10mb' }),
83
  (req, res) => {
84
  if (!Buffer.isBuffer(req.body) || !req.body.length) return res.status(400).json({ error: 'send the image bytes as the request body with an image/* content-type' })
 
90
 
91
  // --- comments / replies ---
92
 
93
+ router.post('/docs/:id/threads/:tid/reply', requireUser, checkDocId, requireDocAccess, async (req, res) => {
94
  const { text, as_agent, mention_id } = req.body || {}
95
  if (!text || typeof text !== 'string') return res.status(400).json({ error: 'text required' })
96
  const identity = agentIdentity(req, as_agent)
 
109
 
110
  // --- suggestions ---
111
 
112
+ router.post('/docs/:id/suggestions', requireUser, checkDocId, requireDocAccess, async (req, res) => {
113
  const { anchor_start, anchor_end, block_index, replacement_markdown, rationale, as_agent, mention_id, thread_id, supersedes } = req.body || {}
114
  if (typeof replacement_markdown !== 'string') return res.status(400).json({ error: 'replacement_markdown (string) required' })
115
  if (anchor_start == null && block_index == null && !supersedes) return res.status(400).json({ error: 'pass block_index or anchor_start/anchor_end (from GET /api/docs/:id blocks), or supersedes' })
 
133
  res.json({ ok: true, suggestion_id: result.suggestion.id })
134
  })
135
 
136
+ router.post('/docs/:id/suggestions/:sid/:action', requireUser, requireHuman, checkDocId, requireDocAccess, async (req, res) => {
137
  if (!['accept', 'reject', 'reopen'].includes(req.params.action)) return res.status(404).json({ error: 'unknown action' })
138
  const result = await collab.resolveSuggestionAction(req.params.id, req.params.sid, req.params.action, req.user.username, {
139
  markOnly: req.body?.mark_only === true,
 
148
  res.json({ agents: collab.agentPresence() })
149
  })
150
 
151
+ router.post('/agents', requireUser, requireHuman, (req, res) => {
152
  const handle = String(req.body?.handle || '').toLowerCase()
153
  if (!HANDLE_RE.test(handle)) return res.status(400).json({ error: 'handle must match ' + HANDLE_RE })
154
  const agents = store.getAgents()
155
  if (agents[handle] && agents[handle].owner !== req.user.username) {
156
  return res.status(409).json({ error: 'handle already registered by another user' })
157
  }
158
+ const key = store.newAgentKey()
159
+ agents[handle] = {
160
+ owner: req.user.username,
161
+ createdAt: agents[handle]?.createdAt || Date.now(),
162
+ keyHash: store.hashAgentKey(key),
163
+ }
164
+ store.saveAgents(agents)
165
+ res.json({ ok: true, handle, key })
166
+ })
167
+
168
+ router.post('/agents/:handle/rotate', requireUser, requireHuman, (req, res) => {
169
+ const agents = store.getAgents()
170
+ const handle = req.params.handle
171
+ if (!agents[handle]) return res.status(404).json({ error: 'not found' })
172
+ if (agents[handle].owner !== req.user.username) return res.status(403).json({ error: 'not your handle' })
173
+ const key = store.newAgentKey()
174
+ agents[handle].keyHash = store.hashAgentKey(key)
175
  store.saveAgents(agents)
176
+ res.json({ ok: true, handle, key })
177
  })
178
 
179
+ router.delete('/agents/:handle', requireUser, requireHuman, (req, res) => {
180
  const agents = store.getAgents()
181
  const handle = req.params.handle
182
  if (!agents[handle]) return res.status(404).json({ error: 'not found' })
 
190
 
191
  router.get('/mentions', requireUser, async (req, res) => {
192
  const wait = Math.max(0, Math.min(Number(req.query.wait) || 0, 55))
193
+ const result = await collab.pollMentions(req.user.username, wait, req.agent?.handle)
194
  if (result.error) return res.status(400).json(result)
195
  res.json(result)
196
  })
 
217
  next()
218
  }
219
 
220
+ // Resolve who a write is attributed to. Requests authenticated with an
221
+ // app-issued agent key ARE that agent; otherwise as_agent must be owned by the caller.
222
  function agentIdentity(req, asAgent) {
223
+ if (req.agent) {
224
+ if (asAgent && String(asAgent).toLowerCase() !== req.agent.handle) {
225
+ return { error: `this key belongs to @${req.agent.handle}, not @${asAgent}` }
226
+ }
227
+ return { author: req.agent.handle, authorType: 'agent' }
228
+ }
229
  if (!asAgent) return { author: req.user.username, authorType: 'user' }
230
  const handle = String(asAgent).toLowerCase()
231
  const agents = store.getAgents()
 
236
 
237
  function agentPrompt(docId, handle) {
238
  const tokenHint = OAUTH_ENABLED
239
+ ? `the agent key that was shown when "@${handle}" was registered (an "ak_..." string; rotate it in the app if lost). It only works for this editor — it is not a Hugging Face credential.`
240
+ : `the dev token "dev:<your-username>" (no real key needed on this dev server)`
241
  return `You are the collaborative-editor agent "@${handle}" for the document ${docId} at ${HOST}.
242
 
243
  You participate in a shared document, but you NEVER edit the text directly. You only:
244
  1. reply to comment threads, and
245
  2. propose suggestions (block replacements) that a human can accept or reject.
246
 
247
+ Authenticate every request with ${tokenHint}
248
+ export AGENT_KEY=<the key>
249
+ AUTH='-H "Authorization: Bearer $AGENT_KEY"'
250
 
251
  Work loop — repeat until the user tells you to stop:
252
 
server/auth.js CHANGED
@@ -54,14 +54,36 @@ export function bearerFromRequest(req) {
54
  return null
55
  }
56
 
57
- // Express middleware: resolves req.user from session cookie or bearer token.
 
58
  export function resolveUser() {
59
  return async (req, res, next) => {
60
- req.user = sessionFromCookieHeader(req.headers.cookie) || (await userFromToken(bearerFromRequest(req)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  next()
62
  }
63
  }
64
 
 
 
 
 
 
 
65
  export function requireUser(req, res, next) {
66
  if (!req.user) return res.status(401).json({ error: 'authentication required (sign in, or pass an HF token as Authorization: Bearer)' })
67
  next()
 
54
  return null
55
  }
56
 
57
+ // Express middleware: resolves req.user from session cookie, app-issued agent
58
+ // key (Bearer ak_...), or HF token. Agent keys also set req.agent = { handle }.
59
  export function resolveUser() {
60
  return async (req, res, next) => {
61
+ const session = sessionFromCookieHeader(req.headers.cookie)
62
+ if (session) {
63
+ req.user = session
64
+ return next()
65
+ }
66
+ const bearer = bearerFromRequest(req)
67
+ if (bearer?.startsWith('ak_')) {
68
+ const { agentByKey } = await import('./store.js')
69
+ const agent = agentByKey(bearer)
70
+ if (agent) {
71
+ req.user = { username: agent.owner, name: agent.owner, avatar: null }
72
+ req.agent = { handle: agent.handle }
73
+ }
74
+ return next()
75
+ }
76
+ req.user = await userFromToken(bearer)
77
  next()
78
  }
79
  }
80
 
81
+ // Some routes are for humans only (accepting suggestions, creating docs, sharing…)
82
+ export function requireHuman(req, res, next) {
83
+ if (req.agent) return res.status(403).json({ error: 'agent keys cannot perform this action — it requires a signed-in human' })
84
+ next()
85
+ }
86
+
87
  export function requireUser(req, res, next) {
88
  if (!req.user) return res.status(401).json({ error: 'authentication required (sign in, or pass an HF token as Authorization: Bearer)' })
89
  next()
server/collab.js CHANGED
@@ -25,10 +25,18 @@ function persistMentions() {
25
 
26
  async function authenticate({ requestHeaders, token, documentName }) {
27
  if (documentName && !DOC_ID_RE.test(documentName)) throw new Error('bad document name')
28
- const user =
29
- (await userFromToken(token && token !== 'cookie' ? token : null)) ||
30
- sessionFromCookieHeader(requestHeaders?.cookie || requestHeaders?.get?.('cookie'))
 
 
 
 
 
 
 
31
  if (!user) throw new Error('unauthorized')
 
32
  return { user }
33
  }
34
 
@@ -108,7 +116,9 @@ function scanDocForMentions(docName, document) {
108
  const key = `${docName}:${threadId}:${msg.id}`
109
  if (mentionState.processed[key]) return
110
  mentionState.processed[key] = true
111
- let handles = explicitHandles(msg.text)
 
 
112
  let implicit = false
113
  if (!handles.length) {
114
  // No explicit tag: deliver to the ONE agent clearly involved in this
@@ -125,7 +135,7 @@ function scanDocForMentions(docName, document) {
125
  for (const h of explicitHandles(prev.text)) candidates.add(h)
126
  }
127
  if (candidates.size === 1) {
128
- handles = [...candidates]
129
  implicit = true
130
  }
131
  }
@@ -292,8 +302,9 @@ async function claimAndEnrich(tasks) {
292
  return out
293
  }
294
 
295
- export function pollMentions(username, waitSeconds) {
296
- const handles = handlesOwnedBy(username)
 
297
  if (!handles.length) return Promise.resolve({ error: 'no agent handles registered for your account — POST /api/agents first' })
298
  for (const h of handles) agentLastPoll.set(h, Date.now())
299
 
 
25
 
26
  async function authenticate({ requestHeaders, token, documentName }) {
27
  if (documentName && !DOC_ID_RE.test(documentName)) throw new Error('bad document name')
28
+ let user = null
29
+ if (token && token !== 'cookie') {
30
+ if (token.startsWith('ak_')) {
31
+ const agent = store.agentByKey(token)
32
+ if (agent) user = { username: agent.owner, name: agent.owner, avatar: null }
33
+ } else {
34
+ user = await userFromToken(token)
35
+ }
36
+ }
37
+ if (!user) user = sessionFromCookieHeader(requestHeaders?.cookie || requestHeaders?.get?.('cookie'))
38
  if (!user) throw new Error('unauthorized')
39
+ if (documentName && !store.canAccessDoc(documentName, user.username)) throw new Error('no access to this document')
40
  return { user }
41
  }
42
 
 
116
  const key = `${docName}:${threadId}:${msg.id}`
117
  if (mentionState.processed[key]) return
118
  mentionState.processed[key] = true
119
+ // an agent only ever receives work for docs its owner can access
120
+ const ownerCanAccess = h => store.canAccessDoc(docName, agents[h]?.owner)
121
+ let handles = explicitHandles(msg.text).filter(ownerCanAccess)
122
  let implicit = false
123
  if (!handles.length) {
124
  // No explicit tag: deliver to the ONE agent clearly involved in this
 
135
  for (const h of explicitHandles(prev.text)) candidates.add(h)
136
  }
137
  if (candidates.size === 1) {
138
+ handles = [...candidates].filter(ownerCanAccess)
139
  implicit = true
140
  }
141
  }
 
302
  return out
303
  }
304
 
305
+ export function pollMentions(username, waitSeconds, onlyHandle = null) {
306
+ let handles = handlesOwnedBy(username)
307
+ if (onlyHandle) handles = handles.filter(h => h === onlyHandle)
308
  if (!handles.length) return Promise.resolve({ error: 'no agent handles registered for your account — POST /api/agents first' })
309
  for (const h of handles) agentLastPoll.set(h, Date.now())
310
 
server/store.js CHANGED
@@ -101,7 +101,18 @@ export function upsertRegistry(id, patch) {
101
  return reg[id]
102
  }
103
 
104
- // --- agent handles ---
 
 
 
 
 
 
 
 
 
 
 
105
 
106
  export function getAgents() {
107
  return readJSON(AGENTS, {})
@@ -111,6 +122,23 @@ export function saveAgents(agents) {
111
  writeJSON(AGENTS, agents)
112
  }
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  // --- mention tasks ---
115
  // shape: { processed: { [messageId]: true }, tasks: { [mentionId]: task } }
116
 
 
101
  return reg[id]
102
  }
103
 
104
+ // --- doc access control ---
105
+
106
+ export function canAccessDoc(id, username) {
107
+ const meta = getRegistry()[id]
108
+ if (!meta || !username) return false
109
+ if (!meta.createdBy) return true // legacy docs created before ACLs
110
+ return meta.createdBy === username || (meta.sharedWith || []).includes(username)
111
+ }
112
+
113
+ // --- agent handles + app-issued keys ---
114
+ // agents.json: { handle: { owner, createdAt, keyHash } } — only the sha256 of
115
+ // the key is stored; the plaintext is shown once at registration/rotation.
116
 
117
  export function getAgents() {
118
  return readJSON(AGENTS, {})
 
122
  writeJSON(AGENTS, agents)
123
  }
124
 
125
+ export function hashAgentKey(key) {
126
+ return crypto.createHash('sha256').update(key).digest('hex')
127
+ }
128
+
129
+ export function newAgentKey() {
130
+ return 'ak_' + crypto.randomBytes(24).toString('hex')
131
+ }
132
+
133
+ export function agentByKey(key) {
134
+ if (!key?.startsWith('ak_')) return null
135
+ const hash = hashAgentKey(key)
136
+ for (const [handle, info] of Object.entries(getAgents())) {
137
+ if (info.keyHash && info.keyHash === hash) return { handle, owner: info.owner }
138
+ }
139
+ return null
140
+ }
141
+
142
  // --- mention tasks ---
143
  // shape: { processed: { [messageId]: true }, tasks: { [mentionId]: task } }
144
 
test/browser.js CHANGED
@@ -55,8 +55,22 @@ async function main() {
55
  await page.fill('#agent-handle', 'ui-agent')
56
  await page.click('#agent-form button')
57
  await page.waitForSelector('.agent-row')
 
 
 
 
58
  await page.click('#agents-btn') // close
59
- console.log('✓ agent registered via header panel')
 
 
 
 
 
 
 
 
 
 
60
 
61
  // --- type into the editor ---
62
  await page.click('.tiptap')
 
55
  await page.fill('#agent-handle', 'ui-agent')
56
  await page.click('#agent-form button')
57
  await page.waitForSelector('.agent-row')
58
+ // registration issues a one-time agent key
59
+ await page.waitForSelector('.key-box code', { timeout: 5000 })
60
+ const shownKey = await page.textContent('.key-box code')
61
+ assert.ok(shownKey.startsWith('ak_'), 'agent key shown once: ' + shownKey.slice(0, 6))
62
  await page.click('#agents-btn') // close
63
+ console.log('✓ agent registered via header panel, key issued')
64
+
65
+ // --- sharing UI ---
66
+ await page.click('#share-btn')
67
+ await page.waitForSelector('#share-pop:not(.hidden)')
68
+ await waitFor(async () => (await page.textContent('#share-list')).includes('owner'), 'share list shows owner')
69
+ await page.fill('#share-user', 'bob')
70
+ await page.click('#share-form button')
71
+ await waitFor(async () => (await page.textContent('#share-list')).includes('bob'), 'bob added to share list')
72
+ await page.click('#share-btn') // close
73
+ console.log('✓ share panel: add collaborator')
74
 
75
  // --- type into the editor ---
76
  await page.click('.tiptap')
test/prod.js CHANGED
@@ -57,6 +57,14 @@ async function main() {
57
 
58
  const reg = await api('/api/agents', { method: 'POST', body: { handle: HANDLE } })
59
  assert.equal(reg.ok, true, JSON.stringify(reg))
 
 
 
 
 
 
 
 
60
 
61
  const threadId = 'prodth' + Math.random().toString(36).slice(2, 8)
62
  doc.transact(() => {
@@ -74,28 +82,27 @@ async function main() {
74
  doc.getMap('threads').set(threadId, t)
75
  })
76
 
77
- const poll = await api('/api/mentions?wait=15')
78
  assert.equal(poll.mentions?.length, 1, JSON.stringify(poll))
79
  console.log('✓ mention delivered via long-poll')
80
 
81
  const snap = await api(`/api/docs/${docId}`)
82
  assert.ok(snap.blocks?.length >= 2)
83
 
84
- const sugg = await api(`/api/docs/${docId}/suggestions`, {
85
  method: 'POST',
86
  body: {
87
  block_index: 1,
88
  replacement_markdown: 'Rewritten by the **prod** smoke agent.',
89
  rationale: 'prod test',
90
- as_agent: HANDLE,
91
  mention_id: poll.mentions[0].mention_id,
92
  thread_id: threadId,
93
  },
94
  })
95
  assert.equal(sugg.ok, true, JSON.stringify(sugg))
96
- const reply = await api(`/api/docs/${docId}/threads/${threadId}/reply`, {
97
  method: 'POST',
98
- body: { text: 'done — see suggestion', as_agent: HANDLE, mention_id: poll.mentions[0].mention_id },
99
  })
100
  assert.equal(reply.ok, true)
101
  console.log('✓ agent reply + suggestion')
@@ -118,16 +125,15 @@ async function main() {
118
  )
119
  const up = await fetch(`${BASE}/api/docs/${docId}/upload`, {
120
  method: 'POST',
121
- headers: { authorization: `Bearer ${TOKEN}`, 'content-type': 'image/png' },
122
  body: pngBytes,
123
  }).then(r => r.json())
124
  assert.ok(up.url?.startsWith('/files/'), 'agent upload: ' + JSON.stringify(up))
125
- const figSugg = await api(`/api/docs/${docId}/suggestions`, {
126
  method: 'POST',
127
  body: {
128
  block_index: 1,
129
  replacement_markdown: `A paragraph with a figure.\n\n![test figure](${up.url})`,
130
- as_agent: HANDLE,
131
  },
132
  })
133
  assert.equal(figSugg.ok, true, JSON.stringify(figSugg))
 
57
 
58
  const reg = await api('/api/agents', { method: 'POST', body: { handle: HANDLE } })
59
  assert.equal(reg.ok, true, JSON.stringify(reg))
60
+ assert.ok(reg.key?.startsWith('ak_'), 'agent key issued')
61
+ const AGENT = reg.key
62
+ const agentApi = (path, opts = {}) =>
63
+ fetch(BASE + path, {
64
+ method: opts.method || 'GET',
65
+ headers: { authorization: `Bearer ${AGENT}`, ...(opts.body ? { 'content-type': 'application/json' } : {}) },
66
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
67
+ }).then(r => r.json())
68
 
69
  const threadId = 'prodth' + Math.random().toString(36).slice(2, 8)
70
  doc.transact(() => {
 
82
  doc.getMap('threads').set(threadId, t)
83
  })
84
 
85
+ const poll = await agentApi('/api/mentions?wait=15')
86
  assert.equal(poll.mentions?.length, 1, JSON.stringify(poll))
87
  console.log('✓ mention delivered via long-poll')
88
 
89
  const snap = await api(`/api/docs/${docId}`)
90
  assert.ok(snap.blocks?.length >= 2)
91
 
92
+ const sugg = await agentApi(`/api/docs/${docId}/suggestions`, {
93
  method: 'POST',
94
  body: {
95
  block_index: 1,
96
  replacement_markdown: 'Rewritten by the **prod** smoke agent.',
97
  rationale: 'prod test',
 
98
  mention_id: poll.mentions[0].mention_id,
99
  thread_id: threadId,
100
  },
101
  })
102
  assert.equal(sugg.ok, true, JSON.stringify(sugg))
103
+ const reply = await agentApi(`/api/docs/${docId}/threads/${threadId}/reply`, {
104
  method: 'POST',
105
+ body: { text: 'done — see suggestion', mention_id: poll.mentions[0].mention_id },
106
  })
107
  assert.equal(reply.ok, true)
108
  console.log('✓ agent reply + suggestion')
 
125
  )
126
  const up = await fetch(`${BASE}/api/docs/${docId}/upload`, {
127
  method: 'POST',
128
+ headers: { authorization: `Bearer ${AGENT}`, 'content-type': 'image/png' },
129
  body: pngBytes,
130
  }).then(r => r.json())
131
  assert.ok(up.url?.startsWith('/files/'), 'agent upload: ' + JSON.stringify(up))
132
+ const figSugg = await agentApi(`/api/docs/${docId}/suggestions`, {
133
  method: 'POST',
134
  body: {
135
  block_index: 1,
136
  replacement_markdown: `A paragraph with a figure.\n\n![test figure](${up.url})`,
 
137
  },
138
  })
139
  assert.equal(figSugg.ok, true, JSON.stringify(figSugg))
test/smoke.js CHANGED
@@ -1,5 +1,5 @@
1
- // End-to-end smoke test: runs the real server, a real Yjs client, and a fake
2
- // agent talking over the HTTP API. No browser needed.
3
  import { spawn } from 'node:child_process'
4
  import fs from 'node:fs'
5
  import path from 'node:path'
@@ -70,12 +70,53 @@ async function main() {
70
  await startServer()
71
  console.log('✓ server started')
72
 
73
- // 1. create a doc as alice
74
  const { id: docId } = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Smoke Test Doc' } })
75
  assert.ok(docId, 'doc created')
76
- console.log('✓ doc created:', docId)
77
 
78
- // 2. connect as alice over websocket, add a paragraph
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  const alice = connect(docId, 'dev:alice')
80
  const frag = alice.doc.getXmlFragment('default')
81
  await waitFor(() => frag.length >= 2, 'initial sync')
@@ -87,16 +128,6 @@ async function main() {
87
  })
88
  console.log('✓ collab sync + edit works')
89
 
90
- // 3. bob registers an agent handle
91
- const reg = await api('dev:bob', '/api/agents', { method: 'POST', body: { handle: 'test-agent' } })
92
- assert.equal(reg.ok, true, 'agent registered: ' + JSON.stringify(reg))
93
-
94
- // handle ownership is enforced
95
- const steal = await api('dev:alice', '/api/agents', { method: 'POST', body: { handle: 'test-agent' } })
96
- assert.ok(steal.error, 'cannot steal a handle')
97
- console.log('✓ agent registration + ownership')
98
-
99
- // 4. alice creates a comment thread mentioning the agent (client-side write)
100
  const threads = alice.doc.getMap('threads')
101
  const anchorStart = Buffer.from(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(frag, 1))).toString('base64url')
102
  const anchorEnd = Buffer.from(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(frag, 2))).toString('base64url')
@@ -116,109 +147,82 @@ async function main() {
116
  threads.set(threadId, t)
117
  })
118
 
119
- // 5. server should detect the mention and set a pending chip
120
  await waitFor(() => {
121
  const msgs = threads.get(threadId)?.get('messages')?.toArray() || []
122
- return msgs[0]?.mentions?.length === 1 && ['pending', 'claimed'].includes(msgs[0].mentions[0].status)
123
  }, 'mention chip')
124
  console.log('✓ mention detected, chip set')
125
 
126
- // 6. agent (bob) long-polls and receives the mention
127
- const poll = await api('dev:bob', '/api/mentions?wait=10')
128
  assert.equal(poll.mentions?.length, 1, 'mention delivered: ' + JSON.stringify(poll))
129
  const mention = poll.mentions[0]
130
  assert.equal(mention.handle, 'test-agent')
131
- assert.equal(mention.doc_id, docId)
132
- assert.ok(mention.instruction.includes('improve'))
133
- console.log('✓ mention long-poll delivery')
134
-
135
- // chip should now read claimed
136
  await waitFor(() => threads.get(threadId).get('messages').toArray()[0].mentions[0].status === 'claimed', 'claimed chip')
137
- console.log('✓ claim acknowledged in doc (👀)')
138
-
139
- // presence: agent should be online
140
  const agents = await api('dev:alice', '/api/agents')
141
  assert.equal(agents.agents.find(a => a.handle === 'test-agent')?.online, true, 'agent online')
142
- console.log('✓ agent presence online')
143
-
144
- // alice can't poll for bob's agent
145
- const wrongPoll = await api('dev:alice', '/api/mentions')
146
- assert.ok(wrongPoll.error, 'alice has no handles')
147
 
148
- // 7. agent reads the doc
149
- const snapshot = await api('dev:bob', `/api/docs/${mention.doc_id}`)
150
  assert.ok(snapshot.markdown.includes('Smoke Test Doc'), 'doc markdown')
151
- assert.ok(snapshot.blocks.length >= 2, 'blocks listed')
152
  assert.ok(snapshot.blocks[1].markdown.includes('quite bad'), 'target block found')
153
- console.log('✓ agent doc read (markdown + blocks)')
154
 
155
- // 8. agent proposes a suggestion + replies
156
- const sugg = await api('dev:bob', `/api/docs/${docId}/suggestions`, {
157
  method: 'POST',
158
  body: {
159
  anchor_start: snapshot.blocks[1].anchor_start,
160
  anchor_end: snapshot.blocks[1].anchor_end,
161
  replacement_markdown: 'This **improved** intro cites [Hugging Face](https://huggingface.co) properly.\n\n- one\n- two',
162
  rationale: 'Clearer wording, added a source.',
163
- as_agent: 'test-agent',
164
  mention_id: mention.mention_id,
165
  thread_id: mention.thread_id,
166
  },
167
  })
168
  assert.equal(sugg.ok, true, 'suggestion created: ' + JSON.stringify(sugg))
169
-
170
- const reply = await api('dev:bob', `/api/docs/${docId}/threads/${mention.thread_id}/reply`, {
171
  method: 'POST',
172
- body: { text: 'Proposed a rewrite with a source — see suggestions.', as_agent: 'test-agent', mention_id: mention.mention_id },
173
  })
174
  assert.equal(reply.ok, true, 'reply posted')
175
 
176
- // agents cannot act as handles they don't own
177
  const forged = await api('dev:alice', `/api/docs/${docId}/threads/${mention.thread_id}/reply`, {
178
  method: 'POST',
179
  body: { text: 'fake', as_agent: 'test-agent' },
180
  })
181
  assert.ok(forged.error, 'as_agent forgery blocked')
182
- console.log('✓ suggestion + reply posted, forgery blocked')
183
 
184
- // 9. chip flips to done; agent message visible in alice's doc
185
  await waitFor(() => threads.get(threadId).get('messages').toArray()[0].mentions[0].status === 'done', 'done chip')
186
  const msgs = threads.get(threadId).get('messages').toArray()
187
  assert.ok(msgs.some(m => m.authorType === 'agent' && m.author === 'test-agent'), 'agent message in thread')
188
- console.log('✓ mention marked done (✅), agent reply in thread')
189
 
190
- // 9b. implicit follow-up: alice replies in the thread WITHOUT tagging;
191
- // the one involved agent gets it anyway, and can dismiss it silently
192
  alice.doc.transact(() => {
193
  threads.get(threadId).get('messages').push([
194
  { id: 'msg-followup', author: 'alice', authorType: 'user', text: 'hmm, can you double check the link?', ts: Date.now() },
195
  ])
196
  })
197
- const followPoll = await api('dev:bob', '/api/mentions?wait=10')
198
- assert.equal(followPoll.mentions?.length, 1, 'follow-up delivered: ' + JSON.stringify(followPoll))
199
  assert.equal(followPoll.mentions[0].implicit_follow_up, true, 'marked implicit')
200
- const dismissed = await api('dev:bob', `/api/mentions/${followPoll.mentions[0].mention_id}/dismiss`, { method: 'POST' })
201
- assert.equal(dismissed.ok, true, 'dismiss works: ' + JSON.stringify(dismissed))
202
- await waitFor(() => {
203
- const msgs = threads.get(threadId).get('messages').toArray()
204
- const follow = msgs.find(m => m.id === 'msg-followup')
205
- return follow?.mentions?.[0]?.status === 'done'
206
- }, 'follow-up chip done after dismiss')
207
- console.log('✓ implicit follow-up delivered + dismissable')
208
 
209
- // 10. suggestion visible in client doc, then alice accepts it
210
  const suggMap = alice.doc.getMap('suggestions')
211
  await waitFor(() => suggMap.size === 1, 'suggestion synced')
212
  const suggestion = [...suggMap.values()][0]
213
- assert.equal(suggestion.status, 'open')
214
- assert.ok(suggestion.originalMarkdown.includes('quite bad'), 'original snapshot captured')
215
-
216
  const accepted = await api('dev:alice', `/api/docs/${docId}/suggestions/${suggestion.id}/accept`, { method: 'POST' })
217
  assert.equal(accepted.ok, true, 'accept: ' + JSON.stringify(accepted))
218
-
219
  await waitFor(() => [...suggMap.values()][0].status === 'accepted', 'suggestion accepted state')
220
 
221
- // 11. the doc content changed and still validates against the tiptap schema
222
  await waitFor(() => {
223
  try {
224
  return yXmlFragmentToProseMirrorRootNode(frag, schema).textContent.includes('improved')
@@ -236,38 +240,32 @@ async function main() {
236
  if (mark.type.name === 'bold') boldTexts.push(node.text)
237
  }
238
  })
239
- // marks must cover exactly the marked-up spans — no bleed into following text
240
- assert.deepEqual(boldTexts, ['improved'], 'bold covers exactly the bold span: ' + JSON.stringify(boldTexts))
241
- assert.deepEqual(linkTexts, ['Hugging Face'], 'link covers exactly the link span: ' + JSON.stringify(linkTexts))
242
- assert.ok(pmRoot.textContent.includes('one'), 'list items applied')
243
- console.log('✓ accept applied replacement; doc validates against tiptap schema (marks + lists ok)')
244
 
245
- // 12. double accept is rejected
246
  const again = await api('dev:alice', `/api/docs/${docId}/suggestions/${suggestion.id}/accept`, { method: 'POST' })
247
  assert.ok(again.error, 'double accept rejected')
248
 
249
- // 13. persistence across restart
250
  alice.provider.destroy()
251
  await stopServer()
252
  await startServer()
253
- const after = await api('dev:alice', `/api/docs/${docId}`)
254
- assert.ok(after.markdown.includes('improved'), 'content survived restart')
255
- assert.ok(after.threads.length === 1 && after.threads[0].messages.length >= 2, 'threads survived restart')
256
- console.log('✓ persistence across restart')
257
-
258
- // 14. doc deletion (creator-only)
259
- const denied = await api('dev:bob', `/api/docs/${docId}`, { method: 'DELETE' })
260
- assert.ok(denied.error, 'non-creator cannot delete')
 
 
261
  const deleted = await api('dev:alice', `/api/docs/${docId}`, { method: 'DELETE' })
262
  assert.equal(deleted.ok, true, 'creator deletes doc')
263
- const gone = await api('dev:alice', `/api/docs/${docId}`)
264
- assert.ok(gone.error, 'doc gone after delete')
265
- console.log('✓ doc deletion (creator-only)')
266
-
267
- // 15. unauthenticated requests are rejected
268
  const anon = await fetch(`${BASE}/api/docs`).then(r => r.status)
269
  assert.equal(anon, 401, 'unauthenticated rejected')
270
- console.log('✓ auth required')
271
 
272
  await stopServer()
273
  fs.rmSync(DATA, { recursive: true, force: true })
 
1
+ // End-to-end smoke test: runs the real server, real Yjs clients, and a fake
2
+ // agent talking over the HTTP API with an app-issued agent key. No browser needed.
3
  import { spawn } from 'node:child_process'
4
  import fs from 'node:fs'
5
  import path from 'node:path'
 
70
  await startServer()
71
  console.log('✓ server started')
72
 
73
+ // 1. alice creates a doc
74
  const { id: docId } = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Smoke Test Doc' } })
75
  assert.ok(docId, 'doc created')
 
76
 
77
+ // 2. ACL: bob cannot see or join the doc before it is shared
78
+ const denied = await api('dev:bob', `/api/docs/${docId}`)
79
+ assert.ok(denied.error, 'unshared doc hidden from bob')
80
+ const bobList = await api('dev:bob', '/api/docs')
81
+ assert.equal(bobList.docs.length, 0, 'doc list filtered')
82
+ const sneaky = connect(docId, 'dev:bob')
83
+ await new Promise(r => setTimeout(r, 1500))
84
+ assert.equal(sneaky.doc.getXmlFragment('default').length, 0, 'ws sync denied for unshared doc')
85
+ sneaky.provider.destroy()
86
+ console.log('✓ ACL: unshared doc invisible to others (list, read, websocket)')
87
+
88
+ // 3. alice shares with bob; bob cannot re-share
89
+ const share = await api('dev:alice', `/api/docs/${docId}/share`, { method: 'POST', body: { username: 'bob' } })
90
+ assert.equal(share.ok, true, 'share: ' + JSON.stringify(share))
91
+ const reshare = await api('dev:bob', `/api/docs/${docId}/share`, { method: 'POST', body: { username: 'carol' } })
92
+ assert.ok(reshare.error, 'only creator can share')
93
+ const bobRead = await api('dev:bob', `/api/docs/${docId}`)
94
+ assert.ok(bobRead.markdown != null, 'bob can read after share')
95
+ console.log('✓ sharing: creator adds bob, bob can read, bob cannot re-share')
96
+
97
+ // 4. bob registers an agent handle -> gets an app-issued key
98
+ const reg = await api('dev:bob', '/api/agents', { method: 'POST', body: { handle: 'test-agent' } })
99
+ assert.equal(reg.ok, true, 'agent registered: ' + JSON.stringify(reg))
100
+ assert.ok(reg.key?.startsWith('ak_'), 'agent key issued')
101
+ const KEY = reg.key
102
+
103
+ const steal = await api('dev:alice', '/api/agents', { method: 'POST', body: { handle: 'test-agent' } })
104
+ assert.ok(steal.error, 'cannot steal a handle')
105
+
106
+ // agent keys are agents, not humans
107
+ const agentDoc = await api(KEY, '/api/docs', { method: 'POST', body: { title: 'nope' } })
108
+ assert.ok(agentDoc.error, 'agent key cannot create docs')
109
+ console.log('✓ agent key issued; human-only actions blocked for keys')
110
+
111
+ // key rotation invalidates the old key
112
+ const rotated = await api('dev:bob', '/api/agents/test-agent/rotate', { method: 'POST' })
113
+ assert.ok(rotated.key?.startsWith('ak_'), 'rotated')
114
+ const oldKeyPoll = await fetch(`${BASE}/api/mentions`, { headers: { authorization: `Bearer ${KEY}` } })
115
+ assert.equal(oldKeyPoll.status, 401, 'old key dead after rotation')
116
+ const AGENT = rotated.key
117
+ console.log('✓ key rotation works, old key revoked')
118
+
119
+ // 5. alice connects over websocket, edits, creates a thread mentioning the agent
120
  const alice = connect(docId, 'dev:alice')
121
  const frag = alice.doc.getXmlFragment('default')
122
  await waitFor(() => frag.length >= 2, 'initial sync')
 
128
  })
129
  console.log('✓ collab sync + edit works')
130
 
 
 
 
 
 
 
 
 
 
 
131
  const threads = alice.doc.getMap('threads')
132
  const anchorStart = Buffer.from(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(frag, 1))).toString('base64url')
133
  const anchorEnd = Buffer.from(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(frag, 2))).toString('base64url')
 
147
  threads.set(threadId, t)
148
  })
149
 
 
150
  await waitFor(() => {
151
  const msgs = threads.get(threadId)?.get('messages')?.toArray() || []
152
+ return msgs[0]?.mentions?.length === 1
153
  }, 'mention chip')
154
  console.log('✓ mention detected, chip set')
155
 
156
+ // 6. the agent long-polls with its key
157
+ const poll = await api(AGENT, '/api/mentions?wait=10')
158
  assert.equal(poll.mentions?.length, 1, 'mention delivered: ' + JSON.stringify(poll))
159
  const mention = poll.mentions[0]
160
  assert.equal(mention.handle, 'test-agent')
 
 
 
 
 
161
  await waitFor(() => threads.get(threadId).get('messages').toArray()[0].mentions[0].status === 'claimed', 'claimed chip')
 
 
 
162
  const agents = await api('dev:alice', '/api/agents')
163
  assert.equal(agents.agents.find(a => a.handle === 'test-agent')?.online, true, 'agent online')
164
+ console.log('✓ mention long-poll with agent key + claim chip + presence')
 
 
 
 
165
 
166
+ // 7. the agent reads the doc with its key
167
+ const snapshot = await api(AGENT, `/api/docs/${mention.doc_id}`)
168
  assert.ok(snapshot.markdown.includes('Smoke Test Doc'), 'doc markdown')
 
169
  assert.ok(snapshot.blocks[1].markdown.includes('quite bad'), 'target block found')
 
170
 
171
+ // 8. suggestion + reply identity comes from the key, no as_agent needed
172
+ const sugg = await api(AGENT, `/api/docs/${docId}/suggestions`, {
173
  method: 'POST',
174
  body: {
175
  anchor_start: snapshot.blocks[1].anchor_start,
176
  anchor_end: snapshot.blocks[1].anchor_end,
177
  replacement_markdown: 'This **improved** intro cites [Hugging Face](https://huggingface.co) properly.\n\n- one\n- two',
178
  rationale: 'Clearer wording, added a source.',
 
179
  mention_id: mention.mention_id,
180
  thread_id: mention.thread_id,
181
  },
182
  })
183
  assert.equal(sugg.ok, true, 'suggestion created: ' + JSON.stringify(sugg))
184
+ const reply = await api(AGENT, `/api/docs/${docId}/threads/${mention.thread_id}/reply`, {
 
185
  method: 'POST',
186
+ body: { text: 'Proposed a rewrite with a source — see suggestions.', mention_id: mention.mention_id },
187
  })
188
  assert.equal(reply.ok, true, 'reply posted')
189
 
 
190
  const forged = await api('dev:alice', `/api/docs/${docId}/threads/${mention.thread_id}/reply`, {
191
  method: 'POST',
192
  body: { text: 'fake', as_agent: 'test-agent' },
193
  })
194
  assert.ok(forged.error, 'as_agent forgery blocked')
195
+ console.log('✓ suggestion + reply via agent key, forgery blocked')
196
 
 
197
  await waitFor(() => threads.get(threadId).get('messages').toArray()[0].mentions[0].status === 'done', 'done chip')
198
  const msgs = threads.get(threadId).get('messages').toArray()
199
  assert.ok(msgs.some(m => m.authorType === 'agent' && m.author === 'test-agent'), 'agent message in thread')
200
+ console.log('✓ mention marked done, agent reply attributed to the handle')
201
 
202
+ // 9. implicit follow-up + dismiss (still via key)
 
203
  alice.doc.transact(() => {
204
  threads.get(threadId).get('messages').push([
205
  { id: 'msg-followup', author: 'alice', authorType: 'user', text: 'hmm, can you double check the link?', ts: Date.now() },
206
  ])
207
  })
208
+ const followPoll = await api(AGENT, '/api/mentions?wait=10')
209
+ assert.equal(followPoll.mentions?.length, 1, 'follow-up delivered')
210
  assert.equal(followPoll.mentions[0].implicit_follow_up, true, 'marked implicit')
211
+ const dismissed = await api(AGENT, `/api/mentions/${followPoll.mentions[0].mention_id}/dismiss`, { method: 'POST' })
212
+ assert.equal(dismissed.ok, true, 'dismiss works')
213
+ console.log('✓ implicit follow-up delivered + dismissable via key')
 
 
 
 
 
214
 
215
+ // 10. agents cannot accept; alice accepts server-side
216
  const suggMap = alice.doc.getMap('suggestions')
217
  await waitFor(() => suggMap.size === 1, 'suggestion synced')
218
  const suggestion = [...suggMap.values()][0]
219
+ const agentAccept = await api(AGENT, `/api/docs/${docId}/suggestions/${suggestion.id}/accept`, { method: 'POST' })
220
+ assert.ok(agentAccept.error, 'agent key cannot accept suggestions')
 
221
  const accepted = await api('dev:alice', `/api/docs/${docId}/suggestions/${suggestion.id}/accept`, { method: 'POST' })
222
  assert.equal(accepted.ok, true, 'accept: ' + JSON.stringify(accepted))
 
223
  await waitFor(() => [...suggMap.values()][0].status === 'accepted', 'suggestion accepted state')
224
 
225
+ // 11. content applied with exact marks, schema-valid
226
  await waitFor(() => {
227
  try {
228
  return yXmlFragmentToProseMirrorRootNode(frag, schema).textContent.includes('improved')
 
240
  if (mark.type.name === 'bold') boldTexts.push(node.text)
241
  }
242
  })
243
+ assert.deepEqual(boldTexts, ['improved'], 'bold covers exactly the bold span')
244
+ assert.deepEqual(linkTexts, ['Hugging Face'], 'link covers exactly the link span')
245
+ console.log(' accept applied replacement; schema + exact marks ok')
 
 
246
 
 
247
  const again = await api('dev:alice', `/api/docs/${docId}/suggestions/${suggestion.id}/accept`, { method: 'POST' })
248
  assert.ok(again.error, 'double accept rejected')
249
 
250
+ // 12. persistence across restart (registry keeps ACL)
251
  alice.provider.destroy()
252
  await stopServer()
253
  await startServer()
254
+ const after = await api('dev:bob', `/api/docs/${docId}`)
255
+ assert.ok(after.markdown.includes('improved'), 'content + share survived restart')
256
+ assert.deepEqual(after.shared_with, ['bob'], 'ACL persisted')
257
+ const afterKey = await api(AGENT, `/api/docs/${docId}`)
258
+ assert.ok(afterKey.markdown, 'agent key survives restart')
259
+ console.log('✓ persistence across restart (content, ACL, agent key)')
260
+
261
+ // 13. deletion + auth
262
+ const deniedDel = await api('dev:bob', `/api/docs/${docId}`, { method: 'DELETE' })
263
+ assert.ok(deniedDel.error, 'non-creator cannot delete')
264
  const deleted = await api('dev:alice', `/api/docs/${docId}`, { method: 'DELETE' })
265
  assert.equal(deleted.ok, true, 'creator deletes doc')
 
 
 
 
 
266
  const anon = await fetch(`${BASE}/api/docs`).then(r => r.status)
267
  assert.equal(anon, 401, 'unauthenticated rejected')
268
+ console.log('✓ deletion (creator-only) + auth required')
269
 
270
  await stopServer()
271
  fs.rmSync(DATA, { recursive: true, force: true })