heyquilt 0.0.1 → 0.3.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +630 -2
  3. package/RELEASES.md +733 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +816 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +563 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +180 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +170 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +98 -0
  19. package/src/ai-vm.js +380 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +130 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +76 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1083 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +251 -0
  40. package/src/api/relay-machines.js +60 -0
  41. package/src/api/routes/access-types.js +54 -0
  42. package/src/api/routes/agent-invites.js +115 -0
  43. package/src/api/routes/agents.js +157 -0
  44. package/src/api/routes/ai-vm.js +369 -0
  45. package/src/api/routes/github.js +380 -0
  46. package/src/api/routes/grants.js +70 -0
  47. package/src/api/routes/invites.js +188 -0
  48. package/src/api/routes/issues.js +47 -0
  49. package/src/api/routes/join.js +167 -0
  50. package/src/api/routes/members.js +110 -0
  51. package/src/api/routes/orgs.js +177 -0
  52. package/src/api/routes/relay-cluster.js +90 -0
  53. package/src/api/routes/relay.js +76 -0
  54. package/src/api/routes/session-invites.js +119 -0
  55. package/src/api/routes/sessions.js +92 -0
  56. package/src/api/routes/teams.js +132 -0
  57. package/src/api/routes/workspace-agents.js +332 -0
  58. package/src/api/routes/workspace-files.js +277 -0
  59. package/src/api/routes/workspace-invites.js +196 -0
  60. package/src/api/routes/workspaces.js +225 -0
  61. package/src/api/server.js +623 -0
  62. package/src/api/slugs.js +20 -0
  63. package/src/api/supabase-store.js +765 -0
  64. package/src/api/team-access.js +30 -0
  65. package/src/api/tokens.js +31 -0
  66. package/src/api/workspace-access.js +62 -0
  67. package/src/api/workspace-reach.js +60 -0
  68. package/src/autowork-tools.js +166 -0
  69. package/src/autowork.js +387 -0
  70. package/src/blobstore.js +99 -0
  71. package/src/boxes.js +37 -0
  72. package/src/branchdocs.js +235 -0
  73. package/src/branches.js +194 -0
  74. package/src/catchup.js +111 -0
  75. package/src/chat-archive.js +85 -0
  76. package/src/chat-links.js +923 -0
  77. package/src/clash.js +140 -0
  78. package/src/cloud-ai.js +50 -0
  79. package/src/commit-request.js +59 -0
  80. package/src/commit.js +213 -0
  81. package/src/connection.js +569 -0
  82. package/src/control.js +245 -0
  83. package/src/conversation.js +125 -0
  84. package/src/doctor.js +156 -0
  85. package/src/duties.js +303 -0
  86. package/src/editors.js +228 -0
  87. package/src/fsutil.js +87 -0
  88. package/src/git-hook.js +48 -0
  89. package/src/git.js +147 -0
  90. package/src/github-commit.js +320 -0
  91. package/src/gitignore.js +49 -0
  92. package/src/gitstate.js +900 -0
  93. package/src/history.js +307 -0
  94. package/src/hooks.js +371 -0
  95. package/src/hosted-agents.js +24 -0
  96. package/src/hosted-client.js +112 -0
  97. package/src/identity.js +99 -0
  98. package/src/inbox.js +237 -0
  99. package/src/integrations.js +221 -0
  100. package/src/key-cli.js +115 -0
  101. package/src/largefiles.js +91 -0
  102. package/src/legacy.js +25 -0
  103. package/src/mcp-proxy.js +40 -0
  104. package/src/mcp-room.js +32 -0
  105. package/src/mcp.js +1261 -0
  106. package/src/merge3.js +158 -0
  107. package/src/merges.js +148 -0
  108. package/src/pass-source.js +176 -0
  109. package/src/passes.js +64 -0
  110. package/src/pathrules.js +127 -0
  111. package/src/persona.js +101 -0
  112. package/src/presence.js +360 -0
  113. package/src/private-file.js +41 -0
  114. package/src/procs.js +70 -0
  115. package/src/project-rules.js +178 -0
  116. package/src/protocol.js +92 -0
  117. package/src/quiet-warnings.js +9 -0
  118. package/src/relay-ai-vm.js +82 -0
  119. package/src/relay-ask.js +141 -0
  120. package/src/relay-ci.js +111 -0
  121. package/src/relay-clash.js +597 -0
  122. package/src/relay-cluster.js +454 -0
  123. package/src/relay-commit.js +887 -0
  124. package/src/relay-filekey.js +46 -0
  125. package/src/relay-git.js +274 -0
  126. package/src/relay-githttp.js +137 -0
  127. package/src/relay-github.js +109 -0
  128. package/src/relay-lease.js +137 -0
  129. package/src/relay-lfs.js +37 -0
  130. package/src/relay-mcp.js +1679 -0
  131. package/src/relay-metrics.js +75 -0
  132. package/src/relay-outbound.js +1144 -0
  133. package/src/relay-sync.js +719 -0
  134. package/src/relay-turn-sync.js +307 -0
  135. package/src/relay-upstream.js +934 -0
  136. package/src/relay-webhooks.js +111 -0
  137. package/src/releases.js +131 -0
  138. package/src/report.js +129 -0
  139. package/src/runner.js +306 -0
  140. package/src/same-edit.js +18 -0
  141. package/src/server.js +4187 -0
  142. package/src/session-access.js +170 -0
  143. package/src/session-name.js +15 -0
  144. package/src/session.js +7361 -0
  145. package/src/settings.js +46 -0
  146. package/src/setup.js +184 -0
  147. package/src/shell-path.js +46 -0
  148. package/src/status.js +199 -0
  149. package/src/summarize.js +109 -0
  150. package/src/sync-words.js +42 -0
  151. package/src/task-comments.js +92 -0
  152. package/src/tasks.js +670 -0
  153. package/src/textdiff.js +20 -0
  154. package/src/turn-sync-plan.js +142 -0
  155. package/src/turn-sync.js +191 -0
  156. package/src/ui/access-form.js +37 -0
  157. package/src/ui/agent-env-key.js +76 -0
  158. package/src/ui/agent-guide.js +127 -0
  159. package/src/ui/agent-kinds.js +175 -0
  160. package/src/ui/agent-place.js +265 -0
  161. package/src/ui/app.css +2309 -0
  162. package/src/ui/app.js +542 -0
  163. package/src/ui/board.js +465 -0
  164. package/src/ui/boxes.js +76 -0
  165. package/src/ui/branches.js +111 -0
  166. package/src/ui/catchup.js +77 -0
  167. package/src/ui/changes.js +159 -0
  168. package/src/ui/chat.js +266 -0
  169. package/src/ui/cloud-ai-model.js +107 -0
  170. package/src/ui/cloud-ai.js +221 -0
  171. package/src/ui/common.js +431 -0
  172. package/src/ui/csv.js +21 -0
  173. package/src/ui/diffview.js +74 -0
  174. package/src/ui/feed-convs.js +39 -0
  175. package/src/ui/feed.js +174 -0
  176. package/src/ui/files.js +579 -0
  177. package/src/ui/fileview.js +123 -0
  178. package/src/ui/git-rule.js +60 -0
  179. package/src/ui/home.js +1323 -0
  180. package/src/ui/index.html +16 -0
  181. package/src/ui/invite.js +97 -0
  182. package/src/ui/invites.js +79 -0
  183. package/src/ui/loom-model.js +516 -0
  184. package/src/ui/loom.css +349 -0
  185. package/src/ui/loom.js +753 -0
  186. package/src/ui/mark.js +104 -0
  187. package/src/ui/merges.js +158 -0
  188. package/src/ui/rail-model.js +78 -0
  189. package/src/ui/rail.js +173 -0
  190. package/src/ui/releases.js +182 -0
  191. package/src/ui/schedule.js +156 -0
  192. package/src/ui/session.js +3394 -0
  193. package/src/ui/signin.js +75 -0
  194. package/src/ui/sounds.js +110 -0
  195. package/src/ui/tool-logo.js +86 -0
  196. package/src/ui/tree.js +276 -0
  197. package/src/ui/workspaces.js +542 -0
  198. package/src/ui-server.js +1272 -0
  199. package/src/update-check.js +67 -0
  200. package/src/upstream.js +155 -0
  201. package/src/webhooks.js +235 -0
  202. package/src/workspace-cli.js +62 -0
  203. package/src/workspace-tools.js +340 -0
  204. package/index.js +0 -2
@@ -0,0 +1,332 @@
1
+ // Agents in workspaces: where an agent works (its placement, set by its owner), a
2
+ // workspace's say over an agent placed there (its override), workspace agent invites,
3
+ // a session owner keeping an agent out of one session, and an agent's own webhook for
4
+ // workspace events. Behind the QUILT_WORKSPACES flag: off, every route answers 404.
5
+ import { HttpError, needId } from '../http.js'
6
+ import { newToken, hashToken } from '../tokens.js'
7
+ import { orgAccess } from '../org-access.js'
8
+ import { cleanAccess, stillInOrg } from '../workspace-access.js'
9
+ import { workspaceReach } from '../workspace-reach.js'
10
+ import { cleanPlacement, placementIn, agentsJoiningSession, sessionAgents, sameOwnerAgent, SESSIONS } from '../agent-placement.js'
11
+ import { AGENT_INVITE_TTL_MS, inviteStatus } from './agent-invites.js'
12
+ import crypto from 'node:crypto'
13
+ import { parseWebhookUrl, newSecret, publicWebhookHost, deliverWebhook, MIN_SECRET, MAX_SECRET } from '../../webhooks.js'
14
+ import { parseInvite, buildInvite } from '../../ui/invite.js'
15
+ import { isHostedRelay, HOSTED_RELAY } from '../../settings.js'
16
+
17
+ const ROOM = /^[A-Za-z0-9_-]{1,64}$/
18
+ export const AGENT_WEBHOOK_EVENTS = ['session.started']
19
+ /** Why someone else's agent in a workspace is never invited to every session there. */
20
+ export const FOREIGN_JOINS = 'Only its owner can have an agent invited to every session.'
21
+
22
+ /** A placement as the routes answer it; an agent with none is 'manual' (it works only where it is added). */
23
+ export function placementView (agentId, p) {
24
+ if (!p) return { agentId, reach: 'manual', workspaceIds: [], sessions: 'invited', access: 'edit', scopes: [], updatedAt: null }
25
+ return { agentId, reach: p.reach, workspaceIds: [...(p.workspaceIds || [])], sessions: p.sessions, access: p.access, scopes: [...(p.scopes || [])], updatedAt: p.updatedAt ?? null }
26
+ }
27
+
28
+ /** A member agent's `sessions`: 'all' or 'invited', and only for an agent. Undefined keeps what the member has. */
29
+ export function cleanMemberSessions (value, kind) {
30
+ if (value === undefined) return undefined
31
+ if (kind !== 'agent') throw new HttpError(400, 'Only an agent joins sessions by itself.')
32
+ if (!SESSIONS.includes(value)) throw new HttpError(400, 'Sessions is all or invited.')
33
+ return value
34
+ }
35
+
36
+ /**
37
+ * The agents in `ws` for its page: member agents (via 'member', managed by the workspace),
38
+ * then agents of the workspace's owner placed there (via 'placed' or 'global', managed by
39
+ * the owner or the org), with the workspace's override applied. Agents the workspace kept
40
+ * out are listed (excluded: true) only for an admin, so they can be let back in. `foreign`
41
+ * marks someone else's agent added here: it joins only when invited, whatever its row says.
42
+ */
43
+ export async function workspaceAgents (store, ws, { admin = false } = {}) {
44
+ const out = []
45
+ const seen = new Set()
46
+ for (const m of await store.listWorkspaceMembers(ws.id)) {
47
+ if (!m.account.startsWith('agent:')) continue
48
+ const agentId = m.account.slice(6)
49
+ const agent = await store.agentById(agentId)
50
+ if (!agent || agent.revokedAt || !(await stillInOrg(store, ws, m.account, null))) continue
51
+ seen.add(agentId)
52
+ const foreign = !(await sameOwnerAgent(store, ws, agentId))
53
+ out.push({ account: m.account, agentId, name: agent.name, provider: agent.provider, via: 'member', access: m.access, sessions: foreign ? 'invited' : (m.sessions || 'invited'), managedBy: 'workspace', excluded: false, foreign })
54
+ }
55
+ const own = ws.orgId ? await store.listOrgAgents(ws.orgId) : ws.ownerUserId ? await store.listPersonalAgents(ws.ownerUserId) : []
56
+ const placed = []
57
+ for (const agent of own) {
58
+ if (seen.has(agent.id)) continue
59
+ const p = await placementIn(store, ws, agent.id)
60
+ if (!p) continue
61
+ const override = await store.workspaceAgentOverride(ws.id, agent.id)
62
+ const excluded = !!override?.excluded
63
+ if (excluded && !admin) continue
64
+ placed.push({ account: `agent:${agent.id}`, agentId: agent.id, name: agent.name, provider: agent.provider, via: p.reach === 'all' ? 'global' : 'placed', access: p.access, sessions: override?.sessions ?? p.sessions, managedBy: ws.orgId ? 'org' : 'owner', excluded, foreign: false })
65
+ }
66
+ placed.sort((a, b) => a.name.localeCompare(b.name))
67
+ return [...out, ...placed]
68
+ }
69
+
70
+ /** Every workspace an agent can find by its placement: its owner's personal ones, or its org's. */
71
+ export async function placementCandidates (store, agent) {
72
+ if (!agent) return []
73
+ if (agent.orgId) return store.listWorkspacesOfOrg(agent.orgId)
74
+ return agent.ownerUserId ? store.listWorkspacesOwnedBy(agent.ownerUserId) : []
75
+ }
76
+
77
+ /** A relay address as join links carry it (ws:// or wss://, no trailing slash). */
78
+ const relayForm = (url) => String(url || '').replace(/\/+$/, '').replace(/^http(s?):\/\//, 'ws$1://')
79
+
80
+ export function workspaceAgentRoutes (ctx) {
81
+ const { store, person, now, apiUrl, limitSend, limitAnnounce = () => {}, log = () => {}, workspaces = false } = ctx
82
+ // The session-started hand-off: how webhooks are sent (tests record them), how host names
83
+ // resolve, and whether a receiver on this computer is allowed (tests only).
84
+ const { relayUrl = HOSTED_RELAY, webhookFetch = globalThis.fetch, webhookLookup, allowLocalWebhooks = false, trackDelivery = (p) => p } = ctx
85
+ const ownRelay = relayForm(relayUrl)
86
+ const { caller, reach } = workspaceReach(ctx)
87
+ const gated = (fn) => (...args) => {
88
+ if (!workspaces) throw new HttpError(404, 'not found')
89
+ return fn(...args)
90
+ }
91
+
92
+ /** A placement body, checked, with every listed workspace one of `owns` (the owner's own). */
93
+ async function placementFrom (body, owns) {
94
+ const p = cleanPlacement(body)
95
+ for (const id of p.workspaceIds) {
96
+ const ws = await store.workspaceById(id)
97
+ if (!ws || !owns(ws)) throw new HttpError(400, 'An agent can only be placed in its owner\'s own workspaces.')
98
+ }
99
+ return p
100
+ }
101
+
102
+ async function myAgent (req, id) {
103
+ const u = await person(req)
104
+ const agent = await store.agentById(needId(id, 'agent'))
105
+ // Someone else's agent, an org's, or a revoked one: the same answer as a missing one.
106
+ if (!agent || agent.ownerUserId !== u.userId || agent.revokedAt) throw new HttpError(404, 'no such agent')
107
+ return { u, agent }
108
+ }
109
+
110
+ async function orgFor (req, slug) {
111
+ const u = await person(req)
112
+ return { u, ...(await orgAccess(store, u.userId, slug)) }
113
+ }
114
+
115
+ async function orgAgent (a, id) {
116
+ const agent = await store.agentById(needId(id, 'agent'))
117
+ if (!agent || agent.orgId !== a.org.id || agent.revokedAt) throw new HttpError(404, 'no such agent')
118
+ return agent
119
+ }
120
+
121
+ /** The room's session when the caller owns it (as the relay reports it). */
122
+ async function ownRoom (req, room) {
123
+ const me = await caller(req)
124
+ if (!ROOM.test(room)) throw new HttpError(404, 'no such session')
125
+ const session = await store.sessionByRoom(room)
126
+ if (!session) throw new HttpError(404, 'no such session')
127
+ if (!session.ownerAccount || session.ownerAccount !== me.account) throw new HttpError(403, 'Only the session owner can do that.')
128
+ return { me, session }
129
+ }
130
+
131
+ /** The room's session when the caller owns it, and the agent to keep out. */
132
+ async function ownSession (req, room, agentId) {
133
+ const { me, session } = await ownRoom(req, room)
134
+ const agent = await store.agentById(needId(agentId, 'agent'))
135
+ if (!agent) throw new HttpError(404, 'no such agent')
136
+ return { me, session, agent }
137
+ }
138
+
139
+ async function meAsAgent (req) {
140
+ const me = await caller(req)
141
+ if (!me.account.startsWith('agent:')) throw new HttpError(403, 'Only an agent has a webhook here.')
142
+ return me.agent || await store.agentById(me.account.slice(6))
143
+ }
144
+
145
+ /**
146
+ * Sends one agent its session.started event, after checking its webhook still points at the
147
+ * public internet as it resolves now (the URL was only checked as written when it was set).
148
+ * Never throws: a skipped or failed send is logged, without the link.
149
+ */
150
+ async function sendSessionStarted (agentId, hook, payload) {
151
+ try {
152
+ let url
153
+ try { url = parseWebhookUrl(hook.url, { allowLocal: allowLocalWebhooks }) } catch (e) {
154
+ log(`session.started to agent ${agentId} skipped: ${hook.url}: ${e.message}`)
155
+ return
156
+ }
157
+ if (!allowLocalWebhooks) {
158
+ const host = await publicWebhookHost(url, webhookLookup ? { lookup: webhookLookup } : {})
159
+ if (!host.ok) { log(`session.started to agent ${agentId} skipped: ${host.reason}`); return }
160
+ }
161
+ await deliverWebhook({ url, secret: hook.secret }, payload, { fetch: webhookFetch, now, log })
162
+ } catch (err) {
163
+ log(`session.started to agent ${agentId} failed: ${err?.message || err}`)
164
+ }
165
+ }
166
+
167
+ const routes = [
168
+ ['GET', /^\/v1\/me\/agents\/([^/]+)\/placement$/, async (req, body, [id]) => {
169
+ const { agent } = await myAgent(req, id)
170
+ return { placement: placementView(agent.id, await store.agentPlacement(agent.id)) }
171
+ }],
172
+
173
+ ['PUT', /^\/v1\/me\/agents\/([^/]+)\/placement$/, async (req, body, [id]) => {
174
+ const { u, agent } = await myAgent(req, id)
175
+ // A personal agent's workspaces are its owner's personal ones, never an org's.
176
+ const p = await placementFrom(body, (ws) => !ws.orgId && ws.ownerUserId === u.userId)
177
+ return { placement: placementView(agent.id, await store.putAgentPlacement({ agentId: agent.id, ...p, updatedBy: `person:${u.userId}` })) }
178
+ }],
179
+
180
+ ['GET', /^\/v1\/orgs\/([^/]+)\/agents$/, async (req, body, [slug]) => {
181
+ const a = await orgFor(req, slug)
182
+ a.need('agents', 'r')
183
+ const agents = await store.listOrgAgents(a.org.id)
184
+ const placements = new Map((await store.listAgentPlacements(agents.map((x) => x.id))).map((p) => [p.agentId, p]))
185
+ return { agents: agents.map((x) => ({ id: x.id, name: x.name, provider: x.provider, type: x.type, hosted: !x.publicKey, placement: placementView(x.id, placements.get(x.id)) })) }
186
+ }],
187
+
188
+ ['PUT', /^\/v1\/orgs\/([^/]+)\/agents\/([^/]+)\/placement$/, async (req, body, [slug, id]) => {
189
+ const a = await orgFor(req, slug)
190
+ a.need('agents', 'u')
191
+ // Placing an agent puts it in the org's workspaces, so it takes Workspaces: Update too.
192
+ a.need('workspaces', 'u')
193
+ const agent = await orgAgent(a, id)
194
+ const p = await placementFrom(body, (ws) => ws.orgId === a.org.id)
195
+ return { placement: placementView(agent.id, await store.putAgentPlacement({ agentId: agent.id, ...p, updatedBy: `person:${a.u.userId}` })) }
196
+ }],
197
+
198
+ // Only for an agent the workspace's owner placed there: an agent added by hand is
199
+ // managed through its member row instead.
200
+ ['PUT', /^\/v1\/workspaces\/([^/]+)\/agents\/([^/]+)$/, async (req, body, [id, agentId]) => {
201
+ const r = await reach(req, id)
202
+ r.needAdmin()
203
+ if (!(await placementIn(store, r.ws, agentId))) throw new HttpError(404, 'that agent is not placed in this workspace')
204
+ const b = body && typeof body === 'object' ? body : {}
205
+ if (b.sessions !== undefined && b.sessions !== null && !SESSIONS.includes(b.sessions)) throw new HttpError(400, 'Sessions is all, invited or null.')
206
+ if (b.excluded !== undefined && typeof b.excluded !== 'boolean') throw new HttpError(400, 'excluded is true or false.')
207
+ const old = await store.workspaceAgentOverride(r.ws.id, agentId)
208
+ const override = await store.putWorkspaceAgentOverride({
209
+ workspaceId: r.ws.id,
210
+ agentId,
211
+ sessions: b.sessions !== undefined ? b.sessions : (old?.sessions ?? null),
212
+ excluded: b.excluded !== undefined ? b.excluded : !!old?.excluded
213
+ })
214
+ return { override }
215
+ }],
216
+
217
+ ['DELETE', /^\/v1\/workspaces\/([^/]+)\/agents\/([^/]+)$/, async (req, body, [id, agentId]) => {
218
+ const r = await reach(req, id)
219
+ r.needAdmin()
220
+ await store.deleteWorkspaceAgentOverride(r.ws.id, needId(agentId, 'agent'))
221
+ return { ok: true }
222
+ }],
223
+
224
+ // A one-time link (good for an hour) for a new agent of the workspace's owner, which
225
+ // joins as a member of this workspace. An org's needs Agents: Create too.
226
+ ['POST', /^\/v1\/workspaces\/([^/]+)\/agent-invites$/, async (req, body, [id]) => {
227
+ const r = await reach(req, id)
228
+ r.needAdmin()
229
+ if (!r.me.userId) throw new HttpError(403, 'agents do not invite agents')
230
+ const workspaceAccess = cleanAccess(body.access ?? 'edit')
231
+ const workspaceSessions = body.sessions ?? 'invited'
232
+ if (!SESSIONS.includes(workspaceSessions)) throw new HttpError(400, 'Sessions is all or invited.')
233
+ let home
234
+ if (r.ws.orgId) {
235
+ const a = await orgAccess(store, r.me.userId, (await store.orgById(r.ws.orgId)).slug)
236
+ a.need('agents', 'c')
237
+ home = { orgId: a.org.id }
238
+ } else home = { ownerUserId: r.ws.ownerUserId }
239
+ limitSend(r.me.userId)
240
+ const token = newToken('qj_')
241
+ const invite = await store.createAgentInvite({ tokenHash: hashToken(token), createdBy: r.me.userId, expiresAt: now() + AGENT_INVITE_TTL_MS, ...home, workspaceId: r.ws.id, workspaceAccess, workspaceSessions })
242
+ return {
243
+ invite: { id: invite.id, kind: invite.orgId ? 'org' : 'personal', status: inviteStatus(invite, now()), workspaceId: invite.workspaceId, workspaceAccess: invite.workspaceAccess, workspaceSessions: invite.workspaceSessions, createdAt: invite.createdAt, expiresAt: invite.expiresAt },
244
+ link: `${apiUrl}/v1/join/${token}`
245
+ }
246
+ }],
247
+
248
+ // The session's agents for its People: the ones its workspace invites (and why), and the
249
+ // ones its owner keeps out (Don't invite), so the owner can change either.
250
+ ['GET', /^\/v1\/sessions\/([^/]+)\/agents$/, async (req, body, [room]) => {
251
+ await ownRoom(req, room)
252
+ return { agents: await sessionAgents(store, room) }
253
+ }],
254
+
255
+ // The agents its owner keeps out of this session, so the app can offer to let them back in.
256
+ ['GET', /^\/v1\/sessions\/([^/]+)\/agents\/excluded$/, async (req, body, [room]) => {
257
+ await ownRoom(req, room)
258
+ const agents = []
259
+ for (const e of await store.listSessionAgentExclusions(room)) agents.push({ agentId: e.agentId, name: (await store.agentById(e.agentId))?.name || '' })
260
+ return { agents }
261
+ }],
262
+
263
+ ['PUT', /^\/v1\/sessions\/([^/]+)\/agents\/([^/]+)\/exclude$/, async (req, body, [room, agentId]) => {
264
+ const { me, agent } = await ownSession(req, room, agentId)
265
+ await store.addSessionAgentExclusion({ room, agentId: agent.id, excludedBy: me.account })
266
+ return { ok: true }
267
+ }],
268
+
269
+ ['DELETE', /^\/v1\/sessions\/([^/]+)\/agents\/([^/]+)\/exclude$/, async (req, body, [room, agentId]) => {
270
+ const { agent } = await ownSession(req, room, agentId)
271
+ await store.removeSessionAgentExclusion(room, agent.id)
272
+ return { ok: true }
273
+ }],
274
+
275
+ // The session's owner, as soon as it has started a session in this workspace, hands over
276
+ // its join link; every agent that joins the session by itself and has a webhook is sent it
277
+ // at once. The link is used for these sends and never kept. 409 until the relay has said
278
+ // who owns the session (the app tries again shortly).
279
+ ['POST', /^\/v1\/workspaces\/([^/]+)\/sessions\/([^/]+)\/started$/, async (req, body, [id, room]) => {
280
+ const r = await reach(req, id)
281
+ if (!ROOM.test(room)) throw new HttpError(404, 'that session is not in this workspace')
282
+ const session = await store.sessionByRoom(room)
283
+ if (!session || session.workspaceId !== r.ws.id) throw new HttpError(404, 'that session is not in this workspace')
284
+ if (session.workspaceLinkedBy !== r.me.account) throw new HttpError(403, 'Only the session owner can do that.')
285
+ if (!session.ownerAccount) throw new HttpError(409, 'Quilt has not heard who owns this session yet; try again in a moment.')
286
+ if (session.ownerAccount !== r.me.account) throw new HttpError(403, 'Only the session owner can do that.')
287
+ let invite
288
+ try { invite = parseInvite(String(body.link || ''), { allowRelay: (s) => isHostedRelay(s) || relayForm(s) === ownRelay }) } catch { throw new HttpError(400, 'link must be this session\'s Quilt invite link') }
289
+ if (invite.room !== room || !invite.secret) throw new HttpError(400, 'link must be this session\'s Quilt invite link')
290
+ // `agents` (optional): only these of the agents it invites, e.g. one invited again.
291
+ const only = body.agents === undefined ? null : body.agents
292
+ if (only !== null && (!Array.isArray(only) || only.length > 100 || only.some((x) => typeof x !== 'string'))) throw new HttpError(400, 'agents must be a list of agent ids.')
293
+ limitAnnounce(room)
294
+ // Sent as Quilt writes it, whatever surrounded it in the request.
295
+ const link = buildInvite({ server: invite.relay || HOSTED_RELAY, room, secret: invite.secret }, isHostedRelay)
296
+ const by = r.me.userId ? ((await store.profile(r.me.userId))?.name || '') : (r.me.agent?.name || '')
297
+ const notified = []
298
+ const withoutWebhook = []
299
+ // An agent that started the session is in it already: it isn't sent its own link.
300
+ const starter = session.ownerAccount.startsWith('agent:') ? session.ownerAccount.slice(6) : null
301
+ for (const { agentId, via } of await agentsJoiningSession(store, room)) {
302
+ if (agentId === starter || (only && !only.includes(agentId))) continue
303
+ const hook = await store.agentWebhook(agentId)
304
+ if (!hook) { withoutWebhook.push(agentId); continue }
305
+ notified.push(agentId)
306
+ const payload = { event: 'session.started', id: crypto.randomUUID(), ts: now(), workspace: { id: r.ws.id, name: r.ws.name }, room, session: room, name: session.name || '', link, by, via }
307
+ trackDelivery(sendSessionStarted(agentId, hook, payload))
308
+ }
309
+ return { notified, withoutWebhook }
310
+ }],
311
+
312
+ // The agent's own webhook for workspace events. The secret signs deliveries: the agent's
313
+ // own (so one webhook, set from a session's relay, signs the same everywhere), or a new one
314
+ // on every PUT without, answered once.
315
+ ['PUT', /^\/v1\/agents\/me\/webhook$/, async (req, body) => {
316
+ const agent = await meAsAgent(req)
317
+ let url
318
+ try { url = parseWebhookUrl(body.url, { allowLocal: allowLocalWebhooks }) } catch (e) { throw new HttpError(400, e.message) }
319
+ const given = body.secret == null ? '' : String(body.secret)
320
+ if (given && (given.length < MIN_SECRET || given.length > MAX_SECRET)) throw new HttpError(400, `secret must be ${MIN_SECRET} to ${MAX_SECRET} characters (or leave it out and Quilt makes one).`)
321
+ const hook = await store.putAgentWebhook({ agentId: agent.id, url, secret: given || newSecret() })
322
+ return { url: hook.url, secret: hook.secret, events: [...AGENT_WEBHOOK_EVENTS] }
323
+ }],
324
+
325
+ ['DELETE', /^\/v1\/agents\/me\/webhook$/, async (req) => {
326
+ const agent = await meAsAgent(req)
327
+ await store.deleteAgentWebhook(agent.id)
328
+ return { ok: true }
329
+ }]
330
+ ]
331
+ return routes.map(([method, pattern, fn, ...options]) => [method, pattern, gated(fn), ...options])
332
+ }
@@ -0,0 +1,277 @@
1
+ // A workspace's file library: the index in Postgres, the bytes behind signed links.
2
+ // Behind the QUILT_WORKSPACES flag like the other workspace routes: off, each answers 404.
3
+ import { HttpError, needId } from '../http.js'
4
+ import { workspaceReach, canSeeFiles } from '../workspace-reach.js'
5
+ import { cleanFilePath, parentOf, nameOf, mimeOf } from '../file-paths.js'
6
+ import { LINK_MS } from '../file-store.js'
7
+
8
+ const MAX_NOTE = 300
9
+ const UNCONFIRMED_MS = 60 * 60 * 1000
10
+ export const DELETED_KEEP_MS = 30 * 24 * 60 * 60 * 1000
11
+ const KEEP_VERSIONS = 10
12
+
13
+ export const fileView = (f) => ({ id: f.id, path: f.path, name: nameOf(f.path), folder: parentOf(f.path), kind: f.kind, size: f.size, mime: f.mime, sha256: f.sha256, version: f.version, note: f.note, uploadedBy: f.uploadedBy, uploadedAt: f.uploadedAt, confirmedAt: f.confirmedAt })
14
+ const MIME = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i
15
+ const LOOPBACK = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i
16
+
17
+ /** The version people get of a file: its current one once that has landed; while a new
18
+ * version is still uploading, the newest earlier one (the row's own facts belong to the
19
+ * pending upload). Null for a file whose first upload hasn't landed. */
20
+ async function servedVersion (store, f) {
21
+ if (f.kind !== 'file' || f.confirmedAt) return null
22
+ return (await store.listWorkspaceFileVersions(f.id))[0] || null
23
+ }
24
+
25
+ /** A row as people see it, or null while a new file's first upload hasn't landed. */
26
+ export async function viewOf (store, f) {
27
+ if (f.kind === 'folder' || f.confirmedAt) return fileView(f)
28
+ const v = await servedVersion(store, f)
29
+ if (!v) return null
30
+ return { ...fileView(f), version: v.version, size: v.size, sha256: v.sha256, note: v.note, uploadedBy: v.uploadedBy, uploadedAt: v.uploadedAt, confirmedAt: v.uploadedAt }
31
+ }
32
+
33
+ /** Live rows people see, as views: folders, and files with a version that landed. */
34
+ export async function listedFiles (store, rows) {
35
+ const views = await Promise.all(rows.map((f) => viewOf(store, f)))
36
+ return views.filter(Boolean)
37
+ }
38
+
39
+ /** A workspace's storage limit. The API's option is a ceiling on the row's own quota: a
40
+ * smaller server (or a test) can lower every workspace's limit without touching the rows.
41
+ * A row with no quota of its own gets the option; a quota of 0 means no storage. */
42
+ export const quotaOf = (ws, ceiling = Infinity) => Math.min(ws.quotaBytes ?? ceiling ?? Infinity, ceiling ?? Infinity)
43
+
44
+ /** What a workspace has used and may use, as the app shows it. */
45
+ export async function usageView (store, ws, { workspaceQuotaBytes, maxWorkspaceFiles } = {}) {
46
+ const { usedBytes, fileCount } = await store.workspaceUsage(ws.id)
47
+ return { usedBytes, quotaBytes: quotaOf(ws, workspaceQuotaBytes), fileCount, maxFiles: maxWorkspaceFiles }
48
+ }
49
+
50
+ /** Removes objects from the file store, 100 keys a call (Supabase's limit per remove).
51
+ * A failed batch is logged and the rest still go: a leftover object costs storage, not data. */
52
+ export async function removeObjects (fileStore, keys, log = () => {}) {
53
+ for (let i = 0; i < keys.length; i += 100) {
54
+ try { await fileStore.remove(keys.slice(i, i + 100)) } catch (err) { log(`file store: ${err.message}`) }
55
+ }
56
+ }
57
+
58
+ export function workspaceFileRoutes (ctx) {
59
+ const { store, now, files: fileStore, apiUrl, maxFileBytes, workspaceQuotaBytes, maxWorkspaceFiles, workspaces = false, log = () => {} } = ctx
60
+ const { reach: reachWorkspace } = workspaceReach(ctx)
61
+ /** The workspace, for a caller who may see its files; to anyone else the files aren't there. */
62
+ const reach = async (req, id) => {
63
+ const r = await reachWorkspace(req, id)
64
+ if (!canSeeFiles(r.access)) throw new HttpError(404, 'not found')
65
+ return r
66
+ }
67
+ const gated = (fn) => async (...a) => { if (!workspaces) throw new HttpError(404, 'not found'); return fn(...a) }
68
+ const cleanNote = (v) => { const s = String(v ?? '').trim(); if (s.length > MAX_NOTE) throw new HttpError(400, `Keep the note under ${MAX_NOTE} characters.`); return s }
69
+ const cleanFilePathOrRoot = (v) => (String(v) === '' ? '' : cleanFilePath(v))
70
+ // The disk store's links are paths on this API, made absolute on apiUrl. A caller on a
71
+ // loopback address (a local or test API, listening on a port apiUrl doesn't know) gets
72
+ // them on the address it used; any other Host header is ignored, so a forged one can't
73
+ // point links elsewhere. Storage links (Supabase) are absolute already and pass through.
74
+ const absolute = (url, req) => {
75
+ if (!url.startsWith('/')) return url
76
+ const host = String(req.headers.host || '')
77
+ return (LOOPBACK.test(host) ? `http://${host}` : apiUrl) + url
78
+ }
79
+
80
+ async function fileIn (r, id) {
81
+ const f = await store.workspaceFileById(needId(id, 'file'))
82
+ if (!f || f.workspaceId !== r.ws.id || f.deletedAt) throw new HttpError(404, 'no such file')
83
+ return f
84
+ }
85
+ const needEdit = (r) => { if (r.access.access !== 'edit') throw new HttpError(403, 'you can only view this workspace') }
86
+
87
+ async function refreshUsage (wsId) {
88
+ const u = await store.workspaceUsage(wsId)
89
+ await store.setWorkspaceUsage(wsId, u)
90
+ return u
91
+ }
92
+
93
+ /** Makes every missing folder on the way to `p` (not `p` itself). */
94
+ async function ensureFolders (ws, p, by) {
95
+ const parts = p.split('/').slice(0, -1)
96
+ for (let i = 1; i <= parts.length; i++) {
97
+ const fp = parts.slice(0, i).join('/')
98
+ const row = await store.workspaceFileByPath(ws.id, fp)
99
+ if (row && row.kind !== 'folder') throw new HttpError(409, `${fp} is a file, not a folder`)
100
+ if (!row) await store.createWorkspaceFile({ workspaceId: ws.id, path: fp, kind: 'folder', uploadedBy: by })
101
+ }
102
+ }
103
+
104
+ const removeKeys = (keys) => removeObjects(fileStore, keys, log)
105
+
106
+ /** Undoes an upload that never landed: a re-upload goes back to the version before it;
107
+ * a new file is forgotten. Either way the failed upload's object is removed. */
108
+ async function abandon (f) {
109
+ await removeKeys([f.objectKey].filter(Boolean))
110
+ if ((await store.listWorkspaceFileVersions(f.id)).length) return store.revertWorkspaceFileVersion(f.id)
111
+ await store.removeWorkspaceFile(f.id)
112
+ return null
113
+ }
114
+
115
+ /** Forgets uploads that never landed, and removes deleted files past their keep time. */
116
+ async function sweep (at = now()) {
117
+ const touched = new Set()
118
+ for (const f of await store.unconfirmedWorkspaceFiles(at - UNCONFIRMED_MS)) {
119
+ await abandon(f)
120
+ touched.add(f.workspaceId)
121
+ }
122
+ for (const wsId of touched) await refreshUsage(wsId)
123
+ await removeKeys(await store.sweepDeletedWorkspaceFiles(at - DELETED_KEEP_MS))
124
+ }
125
+
126
+ const routes = [
127
+ ['GET', /^\/v1\/workspaces\/([^/]+)\/files$/, gated(async (req, body, [id]) => {
128
+ const r = await reach(req, id)
129
+ const folder = new URL(req.url, 'http://x').searchParams.get('folder')
130
+ let rows = await store.listWorkspaceFiles(r.ws.id)
131
+ if (folder !== null) { const want = cleanFilePathOrRoot(folder); rows = rows.filter((f) => parentOf(f.path) === want) }
132
+ return { files: await listedFiles(store, rows) }
133
+ })],
134
+
135
+ // Starts an upload: the row now, the bytes straight to storage through the link, then /done.
136
+ ['POST', /^\/v1\/workspaces\/([^/]+)\/files$/, gated(async (req, body, [id]) => {
137
+ const r = await reach(req, id)
138
+ needEdit(r)
139
+ const p = cleanFilePath(body.path)
140
+ const size = Number(body.size)
141
+ if (!Number.isInteger(size) || size <= 0) throw new HttpError(400, 'Say how many bytes the file is.')
142
+ if (size > maxFileBytes) throw new HttpError(413, 'file too large')
143
+ const note = cleanNote(body.note)
144
+ // A type/subtype and nothing else (no parameters, spaces or line breaks), or the one the name suggests.
145
+ const given = String(body.mime || '')
146
+ const mime = given.length <= 100 && MIME.test(given) ? given : mimeOf(nameOf(p))
147
+ const sha256 = String(body.sha256 || '').slice(0, 64)
148
+ let existing = await store.workspaceFileByPath(r.ws.id, p)
149
+ if (existing && existing.kind === 'folder') throw new HttpError(409, `${p} is a folder`)
150
+ // An earlier upload to this path that hasn't landed. Once its link has expired it
151
+ // never will (a dropped connection): undo it, so it leaves no phantom version or bytes
152
+ // behind. Before then someone may still be uploading it: they go first.
153
+ if (existing && !existing.confirmedAt) {
154
+ // The same person may take over their own attempt (a retry after a dropped upload).
155
+ if (existing.uploadedBy !== r.me.account && now() - existing.uploadedAt <= LINK_MS) throw new HttpError(409, 'someone is uploading this file right now; try again in a moment')
156
+ existing = await abandon(existing)
157
+ }
158
+ const usage = await store.workspaceUsage(r.ws.id)
159
+ if (!existing && usage.fileCount >= maxWorkspaceFiles) throw new HttpError(413, 'this workspace has too many files')
160
+ if (usage.usedBytes + size > quotaOf(r.ws, workspaceQuotaBytes)) throw new HttpError(413, 'this workspace has used its storage')
161
+ await ensureFolders(r.ws, p, r.me.account)
162
+ let file
163
+ if (existing) {
164
+ // The version comes from the store (another upload may have got in since `existing`
165
+ // was read), so the object key, which holds it, is set once the store has answered.
166
+ const v = await store.newWorkspaceFileVersion(existing.id, { size, mime, sha256, objectKey: '', note, uploadedBy: r.me.account, at: now(), keep: KEEP_VERSIONS })
167
+ file = await store.setWorkspaceFileObjectKey(existing.id, `${r.ws.id}/${existing.id}/${v.file.version}`)
168
+ await removeKeys(v.droppedKeys)
169
+ } else {
170
+ // The object key holds the file's id, so it is set once the row exists.
171
+ file = await store.createWorkspaceFile({ workspaceId: r.ws.id, path: p, kind: 'file', size, mime, sha256, note, uploadedBy: r.me.account })
172
+ file = await store.setWorkspaceFileObjectKey(file.id, `${r.ws.id}/${file.id}/1`)
173
+ }
174
+ const upload = await fileStore.uploadTarget(file.objectKey, size)
175
+ return { file: fileView(file), upload: { ...upload, url: absolute(upload.url, req) } }
176
+ })],
177
+
178
+ ['POST', /^\/v1\/workspaces\/([^/]+)\/files\/([^/]+)\/done$/, gated(async (req, body, [id, fid]) => {
179
+ const r = await reach(req, id)
180
+ needEdit(r)
181
+ const f = await fileIn(r, fid)
182
+ if (f.kind !== 'file') throw new HttpError(404, 'no such file')
183
+ // Done twice (a client retrying after a lost answer): nothing to do; the checks below
184
+ // would otherwise count the file's own bytes against the quota and abandon a landed file.
185
+ if (f.confirmedAt) return { file: fileView(f) }
186
+ const there = await fileStore.exists(f.objectKey)
187
+ if (!there) throw new HttpError(409, 'the upload did not land')
188
+ // The declared size was checked when the upload started, but storage may hold more
189
+ // (or fewer) bytes than that: check what actually landed, against the same limits.
190
+ // Usage counts confirmed bytes only, so this row's pending upload is not in it yet.
191
+ const usage = await store.workspaceUsage(r.ws.id)
192
+ const refusal = there.size > maxFileBytes ? new HttpError(413, 'file too large')
193
+ : usage.usedBytes + there.size > quotaOf(r.ws, workspaceQuotaBytes) ? new HttpError(413, 'this workspace has used its storage')
194
+ : there.size !== f.size ? new HttpError(409, 'the upload did not match its declared size')
195
+ : null
196
+ if (refusal) {
197
+ await abandon(f)
198
+ await refreshUsage(r.ws.id)
199
+ throw refusal
200
+ }
201
+ const file = await store.confirmWorkspaceFile(f.id, { size: there.size, at: now() })
202
+ await refreshUsage(r.ws.id)
203
+ return { file: fileView(file) }
204
+ })],
205
+
206
+ ['GET', /^\/v1\/workspaces\/([^/]+)\/files\/([^/]+)\/download$/, gated(async (req, body, [id, fid]) => {
207
+ const r = await reach(req, id)
208
+ const f = await fileIn(r, fid)
209
+ if (f.kind !== 'file') throw new HttpError(400, 'that is a folder')
210
+ const want = new URL(req.url, 'http://x').searchParams.get('version')
211
+ let key = f.objectKey; let size = f.size
212
+ if (want && Number(want) !== f.version) {
213
+ const v = (await store.listWorkspaceFileVersions(f.id)).find((x) => x.version === Number(want))
214
+ if (!v) throw new HttpError(404, 'no such version')
215
+ key = v.objectKey; size = v.size
216
+ } else if (!f.confirmedAt) {
217
+ // A new version is still uploading: with no version asked for, serve the one before it.
218
+ const v = !want && await servedVersion(store, f)
219
+ if (!v) throw new HttpError(409, 'the upload has not landed yet')
220
+ key = v.objectKey; size = v.size
221
+ }
222
+ const name = nameOf(f.path)
223
+ const { url } = await fileStore.downloadTarget(key, { name, type: f.mime })
224
+ return { url: absolute(url, req), expiresAt: now() + LINK_MS, name, mime: f.mime, size }
225
+ })],
226
+
227
+ ['GET', /^\/v1\/workspaces\/([^/]+)\/files\/([^/]+)\/versions$/, gated(async (req, body, [id, fid]) => {
228
+ const r = await reach(req, id)
229
+ const f = await fileIn(r, fid)
230
+ return { versions: (await store.listWorkspaceFileVersions(f.id)).map((v) => ({ version: v.version, size: v.size, sha256: v.sha256, note: v.note, uploadedBy: v.uploadedBy, uploadedAt: v.uploadedAt })) }
231
+ })],
232
+
233
+ // Renames or moves (a folder takes everything in it), or changes the note.
234
+ ['PATCH', /^\/v1\/workspaces\/([^/]+)\/files\/([^/]+)$/, gated(async (req, body, [id, fid]) => {
235
+ const r = await reach(req, id)
236
+ needEdit(r)
237
+ const f = await fileIn(r, fid)
238
+ const patch = {}
239
+ if (body.note !== undefined) patch.note = cleanNote(body.note)
240
+ if (body.path !== undefined) {
241
+ const p = cleanFilePath(body.path)
242
+ if (p !== f.path) {
243
+ if (f.kind === 'folder' && p.startsWith(f.path + '/')) throw new HttpError(400, 'a folder cannot move into itself')
244
+ if (await store.workspaceFileByPath(r.ws.id, p)) throw new HttpError(409, 'something is already at that path')
245
+ await ensureFolders(r.ws, p, r.me.account)
246
+ if (f.kind === 'folder') await store.renameWorkspaceFolder(r.ws.id, f.path, p, now())
247
+ else patch.path = p
248
+ }
249
+ }
250
+ // A folder move has already happened, with nothing left to patch: an empty update is
251
+ // an error on Supabase, so read the row back instead.
252
+ const file = Object.keys(patch).length ? await store.updateWorkspaceFile(f.id, patch) : await store.workspaceFileById(f.id)
253
+ return { file: (await viewOf(store, file)) || fileView(file) }
254
+ })],
255
+
256
+ // Soft: the bytes stay 30 days (the sweep removes them); a folder takes its contents.
257
+ ['DELETE', /^\/v1\/workspaces\/([^/]+)\/files\/([^/]+)$/, gated(async (req, body, [id, fid]) => {
258
+ const r = await reach(req, id)
259
+ needEdit(r)
260
+ const f = await fileIn(r, fid)
261
+ await store.deleteWorkspaceFile(f.id, now())
262
+ await refreshUsage(r.ws.id)
263
+ return { ok: true }
264
+ })],
265
+
266
+ ['POST', /^\/v1\/workspaces\/([^/]+)\/folders$/, gated(async (req, body, [id]) => {
267
+ const r = await reach(req, id)
268
+ needEdit(r)
269
+ const p = cleanFilePath(body.path)
270
+ if (await store.workspaceFileByPath(r.ws.id, p)) throw new HttpError(409, 'something is already at that path')
271
+ await ensureFolders(r.ws, p, r.me.account)
272
+ return { file: fileView(await store.createWorkspaceFile({ workspaceId: r.ws.id, path: p, kind: 'folder', uploadedBy: r.me.account })) }
273
+ })]
274
+ ]
275
+
276
+ return { routes, sweep }
277
+ }