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,623 @@
1
+ // src/api/server.js
2
+ // The Quilt accounts API: links desktop apps to accounts (a device-code flow, like
3
+ // signing in to a TV app) and manages agents. Plain node:http, like the relay.
4
+ import http from 'node:http'
5
+ import { pipeline } from 'node:stream/promises'
6
+ import fs from 'node:fs'
7
+ import os from 'node:os'
8
+ import path from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+ import { newToken, hashToken, newUserCode, normalizeUserCode } from './tokens.js'
11
+ import { parsePublicKey, verifyDeviceLink, verifyDeviceResume } from '../identity.js'
12
+ import { signPass, passPublicKey, PASS_VERSION, PASS_TTL_MS } from '../passes.js'
13
+ import { HttpError, Raw } from './http.js'
14
+ import { DiskStore } from './file-store.js'
15
+ import { contentDisposition } from './file-paths.js'
16
+ import { orgRoutes } from './routes/orgs.js'
17
+ import { memberRoutes } from './routes/members.js'
18
+ import { teamRoutes } from './routes/teams.js'
19
+ import { inviteRoutes } from './routes/invites.js'
20
+ import { agentRoutes } from './routes/agents.js'
21
+ import { makeAgentAuth, isAgentKey } from './agent-auth.js'
22
+ import { agentInviteRoutes } from './routes/agent-invites.js'
23
+ import { joinRoutes } from './routes/join.js'
24
+ import { relayRoutes } from './routes/relay.js'
25
+ import { relayClusterRoutes } from './routes/relay-cluster.js'
26
+ import { relayCluster } from './relay-cluster.js'
27
+ import crypto from 'node:crypto'
28
+ import { githubRoutes } from './routes/github.js'
29
+ import { githubApp, ensureWebhookSecret, redeliverFailed } from './github-app.js'
30
+ import { sessionRoutes } from './routes/sessions.js'
31
+ import { accessTypeRoutes } from './routes/access-types.js'
32
+ import { grantRoutes } from './routes/grants.js'
33
+ import { sessionInviteRoutes } from './routes/session-invites.js'
34
+ import { workspaceRoutes } from './routes/workspaces.js'
35
+ import { workspaceFileRoutes } from './routes/workspace-files.js'
36
+ import { workspaceAgentRoutes } from './routes/workspace-agents.js'
37
+ import { workspaceInviteRoutes } from './routes/workspace-invites.js'
38
+ import { HOSTED_RELAY } from '../settings.js'
39
+ import { roomAccessFrom } from './access.js'
40
+ import { joiningRoom, sessionArg } from '../mcp-room.js'
41
+ import { issueRoutes } from './routes/issues.js'
42
+ import { aiVmRoutes, cloudAiOf, WAIT_MS as AI_VM_WAIT_MS } from './routes/ai-vm.js'
43
+ import { personaName } from '../persona.js'
44
+ import { CLOUD_LABEL } from '../cloud-ai.js'
45
+ import { routeName, cleanEvent } from './issues.js'
46
+
47
+ const API_VERSION = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'), 'utf8')).version
48
+
49
+ const LINK_TTL_MS = 10 * 60 * 1000
50
+ const ROOM = /^[A-Za-z0-9_-]{1,64}$/
51
+ // An approved link the app never collects stops working this long after its code expires.
52
+ const COLLECT_GRACE_MS = 5 * 60 * 1000
53
+ // A linked computer signing back in with its key: how far its clock may be from ours.
54
+ const RESUME_WINDOW_MS = 10 * 60 * 1000
55
+ const POLL_INTERVAL_S = 3
56
+ const MAX_BODY = 16 * 1024
57
+ // A hosted agent's MCP request (a whole file, at most) and how long it may take on the relay.
58
+ const MAX_MCP_BODY = 2 * 1024 * 1024
59
+ // With workspaces on, room for a 2 MB library file sent as base64 (a third larger) and its call
60
+ // around it: the relay's MCP reads up to 4 MB.
61
+ const MAX_MCP_BODY_WORKSPACES = 4 * 1024 * 1024
62
+ const MCP_TIMEOUT_MS = 30 * 1000
63
+ // A pass minted for a hosted agent is reused until this close to its end: 5 minutes, so
64
+ // a change to its grant reaches it as soon as it reaches a connected app.
65
+ const PASS_REUSE_MARGIN_MS = 5 * 60 * 1000
66
+
67
+ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, siteUrl, apiUrl = 'https://api.heyquilt.com', mailer = { send: async () => { throw new Error('no mailer configured') } }, now = Date.now, log = () => {}, startLimit = 10, inviteLimit = 10, inviteSendLimit = 20, tokenLimit = 30, joinLimit = 20, trustProxy = false, maxStartKeys = 10_000, passKey = '', passLimit = 60, relayUrl = HOSTED_RELAY, mcpLimit = 600, reportKey = '', reportLimit = 10, slowMs = 2000, pruneEveryMs = 60 * 60 * 1000, keepEventsMs = 30 * 24 * 60 * 60 * 1000, keepIssuesMs = 90 * 24 * 60 * 60 * 1000, pruneStartMs = 10_000, relaySecret = '', workspaces = false, fileStore = null, maxFileBytes = 500 * 1024 * 1024, workspaceQuotaBytes = 5 * 1024 * 1024 * 1024, maxWorkspaceFiles = 2000, webhookFetch = globalThis.fetch, webhookLookup, allowLocalWebhooks = false, github = null, githubFetch = globalThis.fetch, fly = null, aiVmWaitMs = AI_VM_WAIT_MS, relayFetch = null, relayFly = null, heavy = null, clusterTiming = {} }) {
68
+ // PASS_SIGNING_KEY. A bad one should stop the API at start, not fail every pass later.
69
+ if (passKey) passPublicKey(passKey)
70
+ const site = String(siteUrl || '').replace(/\/+$/, '')
71
+ // Where agents reach this API: invite links and the join instructions point here.
72
+ const api = String(apiUrl).replace(/\/+$/, '')
73
+ // Workspace files' bytes: the API's own disk (signed links it serves itself) unless a
74
+ // store (Supabase) was given. Defaulted so tests and a plain local run need nothing.
75
+ const files = fileStore || new DiskStore(fs.mkdtempSync(path.join(os.tmpdir(), 'quilt-api-files-')))
76
+ // The relay that hosts the agents' MCP (/mcp here hands requests on to it).
77
+ const relay = String(relayUrl).replace(/\/+$/, '').replace(/^ws(s?):\/\//, 'http$1://')
78
+
79
+ const bearer = (req) => (String(req.headers.authorization || '').match(/^Bearer\s+(.+)$/i) || [])[1] || ''
80
+ async function user (req) {
81
+ const u = await verifyUser(bearer(req))
82
+ if (!u) throw new HttpError(401, 'sign in first')
83
+ return u
84
+ }
85
+
86
+ async function device (req) {
87
+ const d = await store.deviceByToken(hashToken(bearer(req)))
88
+ if (!d) throw new HttpError(401, 'this computer is signed out')
89
+ await store.touchDevice(d.id)
90
+ return d
91
+ }
92
+
93
+ /** A signed-in person, from the website (JWT) or from the app on a linked computer (qd_ token). */
94
+ async function person (req) {
95
+ if (bearer(req).startsWith('qd_')) return { userId: (await device(req)).userId, email: '' }
96
+ return user(req)
97
+ }
98
+
99
+ // A few tries per minute per address is plenty for a person. Behind Fly,
100
+ // Fly-Client-IP is the real peer; X-Forwarded-For isn't used because Fly appends
101
+ // to whatever the client sent, so its first entry is client-controlled.
102
+ // `keyOf` lets a limiter key on something other than the caller's IP (e.g. a
103
+ // signed-in user id for invite sending) and `windowMs` lets it use a longer window.
104
+ function makeLimiter (limit, message, { windowMs = 60_000, keyOf } = {}) {
105
+ const hits = new Map()
106
+ const byIp = (req) => (trustProxy && String(req.headers['fly-client-ip'] || '').trim()) || req.socket.remoteAddress
107
+ const check = (req) => {
108
+ const key = (keyOf || byIp)(req)
109
+ const recent = (hits.get(key) || []).filter((t) => now() - t < windowMs)
110
+ if (recent.length >= limit) throw new HttpError(429, message)
111
+ hits.set(key, [...recent, now()])
112
+ // Keep the map bounded: forget keys with nothing in the last window.
113
+ if (hits.size > maxStartKeys) for (const [k, ts] of hits) if (ts.every((t) => now() - t >= windowMs)) hits.delete(k)
114
+ }
115
+ check.size = () => hits.size
116
+ return check
117
+ }
118
+ const limitStarts = makeLimiter(startLimit, 'too many sign-in attempts; try again in a minute')
119
+ const limitInvites = makeLimiter(inviteLimit, 'too many tries; wait a minute and try again')
120
+ // Sending (or resending) an email invite, capped per signed-in user rather than per IP.
121
+ const limitInviteSend = makeLimiter(inviteSendLimit, 'too many invites sent; wait a bit and try again', { windowMs: 60 * 60_000, keyOf: (userId) => userId })
122
+ const limitTokens = makeLimiter(tokenLimit, 'too many key refreshes; try again in a minute')
123
+ const limitJoin = makeLimiter(joinLimit, 'too many tries; wait a minute and try again')
124
+ // Session-started hand-offs, per room: a session is announced once, so a few covers retries.
125
+ const limitAnnounce = makeLimiter(5, 'this session was announced a moment ago', { windowMs: 10 * 60_000, keyOf: (room) => room })
126
+ // Passes, per token (keyed on its hash, counted once the token checks out).
127
+ const limitPasses = makeLimiter(passLimit, 'too many passes; try again in a minute', { keyOf: (tokenHash) => tokenHash })
128
+ // Reports with no sign-in (the app before it's linked): a few batches a minute per address.
129
+ const limitReports = makeLimiter(reportLimit, 'too many reports; try again in a minute')
130
+ const agentAuth = makeAgentAuth({ store, now, bearer })
131
+ // Hosted MCP calls, per agent.
132
+ const limitMcp = makeLimiter(mcpLimit, 'too many requests; slow down', { keyOf: (agentId) => agentId })
133
+
134
+ /** Who a pass is for: a linked computer's account, or an agent and the key it registered. */
135
+ async function passHolder (req) {
136
+ if (isAgentKey(bearer(req))) {
137
+ const { agent } = await agentAuth.agentFromRequest(req)
138
+ // No key: a hosted agent. Its pass works over HTTPS (the relay's /mcp), never for a WebSocket.
139
+ return agentHolder(agent)
140
+ }
141
+ const d = await device(req)
142
+ const p = await store.profile(d.userId)
143
+ return { sub: d.userId, kind: 'person', name: ((p && p.name) || 'Quilt user').slice(0, 64), key: d.publicKey }
144
+ }
145
+
146
+ /**
147
+ * Who a pass for an agent is for. A person's cloud AI (routes/ai-vm.js) is one of its person's
148
+ * AI sessions: named like one ("Dan · cloud"), with its person's account (`of`) and name
149
+ * (`ofName`), which the relay lets in where its person stands.
150
+ */
151
+ async function agentHolder (agent) {
152
+ const holder = { sub: agent.id, kind: 'agent', name: agent.name.slice(0, 64), key: agent.publicKey || '' }
153
+ const vm = await cloudAiOf(store, agent)
154
+ if (!vm) return holder
155
+ const p = await store.profile(vm.userId)
156
+ const person = String((p && p.name) || 'Quilt user').slice(0, 64)
157
+ return { ...holder, name: personaName(person, CLOUD_LABEL).slice(0, 64), of: `person:${vm.userId}`, ofName: person }
158
+ }
159
+
160
+ // Resume signatures seen in the last window, so a copied one can't be used again: here, and in
161
+ // the store, so it can't be used once on each API machine either.
162
+ const usedResumes = new Map() // signature -> at
163
+ async function spendResume (signature, at) {
164
+ for (const [sig, t] of usedResumes) if (Math.abs(now() - t) > RESUME_WINDOW_MS) usedResumes.delete(sig)
165
+ if (usedResumes.has(signature)) throw new HttpError(401, 'that sign-in was already used')
166
+ usedResumes.set(signature, at)
167
+ let fresh = true
168
+ try { fresh = await store.spendResumeSignature(crypto.createHash('sha256').update(signature).digest('hex'), RESUME_WINDOW_MS) } catch (err) { log(`resume signatures: ${err?.message || err}`) }
169
+ if (!fresh) throw new HttpError(401, 'that sign-in was already used')
170
+ }
171
+
172
+ const needPassKey = () => { if (!passKey) throw new HttpError(503, 'passes are not set up on this server') }
173
+
174
+ /**
175
+ * A signed pass for `holder`. For a room, it also carries the room, when it was issued
176
+ * (the relay won't let an older pass undo a removal), what its holder may do there
177
+ * (see access.js), and a person's confirmed email.
178
+ */
179
+ async function mintPass (holder, room) {
180
+ const iat = now()
181
+ const exp = iat + PASS_TTL_MS
182
+ const payload = { v: PASS_VERSION, ...holder, exp }
183
+ if (room) {
184
+ // A person's cloud AI has its person's grant here (agentHolder).
185
+ const personId = holder.kind === 'person' ? holder.sub : holder.of ? holder.of.slice('person:'.length) : null
186
+ const mail = personId ? await store.userEmail(personId) : null
187
+ const email = mail?.confirmed ? String(mail.email || '').toLowerCase() : ''
188
+ let { access, from } = await roomAccessFrom(store, room, holder.of || `${holder.kind}:${holder.sub}`, email)
189
+ // The owner's AI edits like them, but is never the owner.
190
+ if (holder.of && access && access.owner) { const { owner, ...rest } = access; access = rest; from = null }
191
+ // `from`: why they may (a grant here, the workspace, the org), shown on the session's member list.
192
+ Object.assign(payload, { room, iat, access }, from ? { from } : {}, email && holder.kind === 'person' ? { email } : {})
193
+ }
194
+ return { pass: signPass(payload, passKey), expiresAt: exp }
195
+ }
196
+
197
+ /** A person's profile and sign-in email, which the app keeps in account.json. */
198
+ async function profileWithEmail (userId) {
199
+ const p = await store.profile(userId)
200
+ return p && { ...p, email: (await store.userEmail(userId))?.email || '' }
201
+ }
202
+
203
+ const COLOR = /^#[0-9a-fA-F]{6}$/
204
+ function cleanProfile (b) {
205
+ const out = {}
206
+ if (b.name !== undefined) { const n = String(b.name).trim().slice(0, 60); if (!n) throw new HttpError(400, 'name is empty'); out.name = n }
207
+ if (b.color !== undefined) { if (b.color !== null && !COLOR.test(b.color)) throw new HttpError(400, 'color must be #RRGGBB'); out.color = b.color }
208
+ if (b.tool !== undefined) out.tool = b.tool === null ? null : String(b.tool).slice(0, 40)
209
+ return out
210
+ }
211
+
212
+ const routes = [
213
+ ['GET', /^\/healthz$/, async () => ({ ok: true })],
214
+
215
+ // Which optional parts this API has on, for the app and the MCPs to decide what to show. Public.
216
+ ['GET', /^\/v1\/features$/, async () => ({ workspaces: !!workspaces })],
217
+
218
+ ['POST', /^\/v1\/device\/start$/, async (req, body) => {
219
+ limitStarts(req)
220
+ const { publicKey, deviceName, platform } = body
221
+ if (!parsePublicKey(publicKey)) throw new HttpError(400, 'publicKey must be an Ed25519 key (spki, base64url)')
222
+ const deviceCode = newToken('dc_')
223
+ let userCode
224
+ do userCode = newUserCode(); while (await store.linkByUserCode(userCode))
225
+ await store.createLink({
226
+ deviceCodeHash: hashToken(deviceCode), userCode, publicKey,
227
+ deviceName: String(deviceName || 'A computer').slice(0, 80), platform: String(platform || '').slice(0, 20),
228
+ expiresAt: now() + LINK_TTL_MS
229
+ })
230
+ return { deviceCode, userCode, verificationUrl: `${site}/link?code=${userCode}`, interval: POLL_INTERVAL_S, expiresIn: LINK_TTL_MS / 1000 }
231
+ }],
232
+
233
+ ['POST', /^\/v1\/device\/poll$/, async (req, body) => {
234
+ const link = await store.linkByDeviceCode(hashToken(body.deviceCode))
235
+ if (!link) throw new HttpError(404, 'unknown device code')
236
+ const waiting = link.status === 'pending' || link.status === 'approving'
237
+ if (link.status === 'consumed' || (waiting && link.expiresAt < now())) throw new HttpError(410, 'expired')
238
+ if (link.status === 'approved' && link.expiresAt + COLLECT_GRACE_MS < now()) throw new HttpError(410, 'expired')
239
+ if (link.status === 'denied') throw new HttpError(403, 'denied')
240
+ // Pending polls skip the signature check so the app can poll cheaply; they
241
+ // reveal nothing. Only collecting the token needs proof of the key.
242
+ if (waiting) return [202, { status: 'pending' }]
243
+ // Proof of possession: anyone can start a link with a computer's public key
244
+ // (it's shared with session members), but only the computer can sign for it.
245
+ if (!verifyDeviceLink(parsePublicKey(link.publicKey), String(body.deviceCode), body.signature)) throw new HttpError(401, "this computer's signature doesn't match")
246
+ // Approved: claim the link before minting, so two polls racing on the same
247
+ // link can't both win a token — only the caller that flips it gets one.
248
+ if (!await store.claimLink(link.id, 'approved', 'consumed')) throw new HttpError(410, 'expired')
249
+ const token = newToken('qd_')
250
+ await store.setDeviceToken(link.deviceId, hashToken(token))
251
+ return { status: 'approved', token, profile: await profileWithEmail(link.userId) }
252
+ }],
253
+
254
+ // A computer that was linked (and not unlinked since) gets a new token by signing for its
255
+ // key: it lost its token (or another app on it replaced it), not its link. No browser needed.
256
+ // 404 means it isn't linked to anyone, and has to be approved on the website.
257
+ ['POST', /^\/v1\/device\/resume$/, async (req, body) => {
258
+ limitStarts(req)
259
+ const key = parsePublicKey(body.publicKey)
260
+ if (!key) throw new HttpError(400, 'publicKey must be an Ed25519 key (spki, base64url)')
261
+ const at = Number(body.at)
262
+ if (!Number.isFinite(at) || Math.abs(now() - at) > RESUME_WINDOW_MS) throw new HttpError(400, "this computer's clock is off; check its date and time")
263
+ if (!verifyDeviceResume(key, at, body.signature)) throw new HttpError(401, "this computer's signature doesn't match")
264
+ await spendResume(String(body.signature), at)
265
+ const d = await store.deviceByPublicKey(String(body.publicKey))
266
+ if (!d) throw new HttpError(404, 'this computer is not linked to an account')
267
+ const token = newToken('qd_')
268
+ await store.setDeviceToken(d.id, hashToken(token))
269
+ await store.touchDevice(d.id)
270
+ return { status: 'approved', token, profile: await profileWithEmail(d.userId) }
271
+ }],
272
+
273
+ // `known`: this account linked this computer's key before, so the website approves it
274
+ // without asking (only the computer holding the key can collect the token).
275
+ ['GET', /^\/v1\/device\/link\/([^/]+)$/, async (req, body, [code]) => {
276
+ const u = await user(req)
277
+ const link = await openLink(code)
278
+ return { userCode: link.userCode, deviceName: link.deviceName, platform: link.platform, expiresAt: link.expiresAt, known: await store.userHasDevice(u.userId, link.publicKey) }
279
+ }],
280
+
281
+ ['POST', /^\/v1\/device\/approve$/, async (req, body) => {
282
+ const u = await user(req)
283
+ const link = await openLink(body.userCode)
284
+ // Claim the link first, so two approvals racing on one code can't both make a device.
285
+ if (!await store.claimLink(link.id, 'pending', 'approving')) throw new HttpError(410, 'this code has expired or was already used')
286
+ if (!body.approve) { await store.updateLink(link.id, { status: 'denied', userId: u.userId }); return { status: 'denied' } }
287
+ let device
288
+ try {
289
+ device = await store.upsertDevice({ userId: u.userId, name: link.deviceName, platform: link.platform, publicKey: link.publicKey })
290
+ } catch (err) {
291
+ // Put the link back so the person can retry rather than being stuck mid-approval.
292
+ await store.updateLink(link.id, { status: 'pending' }).catch(() => {})
293
+ throw err
294
+ }
295
+ await store.updateLink(link.id, { status: 'approved', userId: u.userId, deviceId: device.id })
296
+ return { status: 'approved', device: { id: device.id, name: device.name } }
297
+ }],
298
+
299
+ ['GET', /^\/v1\/me$/, async (req) => {
300
+ const d = await device(req)
301
+ return { profile: await profileWithEmail(d.userId), device: { id: d.id, name: d.name } }
302
+ }],
303
+
304
+ ['PUT', /^\/v1\/me\/profile$/, async (req, body) => {
305
+ const d = await device(req)
306
+ const patch = cleanProfile(body)
307
+ if (!Object.keys(patch).length) return { profile: await store.profile(d.userId) }
308
+ return { profile: await store.updateProfile(d.userId, patch) }
309
+ }],
310
+
311
+ ['POST', /^\/v1\/me\/signout$/, async (req) => {
312
+ const d = await device(req)
313
+ await store.revokeDevice(d.id)
314
+ return { ok: true }
315
+ }],
316
+
317
+ // A pass lets its holder into sessions on the relay for 10 minutes (see src/passes.js).
318
+ // With { room }, it is for that room only and carries what its holder may do there.
319
+ ['POST', /^\/v1\/passes$/, async (req, body) => {
320
+ needPassKey()
321
+ const holder = await passHolder(req)
322
+ limitPasses(hashToken(bearer(req)))
323
+ if (body.room !== undefined && !ROOM.test(String(body.room))) throw new HttpError(400, 'room must be a session name')
324
+ return mintPass(holder, body.room)
325
+ }],
326
+
327
+ // The relay's QUILT_PASS_PUBLIC_KEY. Public: it only checks passes.
328
+ ['GET', /^\/v1\/passes\/key$/, async () => {
329
+ needPassKey()
330
+ return { publicKey: passPublicKey(passKey) }
331
+ }],
332
+
333
+ ['DELETE', /^\/v1\/me\/account$/, async (req) => {
334
+ const u = await user(req)
335
+ // Every org keeps exactly one owner, so an owner hands it on (or deletes it) first.
336
+ if ((await store.orgsForUser(u.userId)).some((o) => o.ownerId === u.userId)) throw new HttpError(409, 'you own an org; transfer it or delete it first')
337
+ // Their cloud AI machine and its disk go first: nothing of theirs keeps running.
338
+ try { await aiVm.destroyFor(u.userId) } catch (err) { throw new HttpError(502, `Couldn't delete your cloud AI machine; try again. ${err.message}`) }
339
+ await store.deleteUser(u.userId)
340
+ return { ok: true }
341
+ }]
342
+ ]
343
+
344
+ // Org routes live in their own modules and share the caller check and the limiter.
345
+ // Webhook deliveries in flight (the session-started hand-off), so tests can wait for them.
346
+ const deliveries = new Set()
347
+ const trackDelivery = (p) => { deliveries.add(p); p.finally(() => deliveries.delete(p)).catch(() => {}); return p }
348
+ // The relay on many machines: where each room lives, and heavy rooms' own machines (relay-cluster.js).
349
+ const cluster = relayCluster({ store, relayUrl: relay, relaySecret, fetch: relayFetch || githubFetch, now, log, fly: relayFly, ...(heavy ? { limits: heavy } : {}), ...clusterTiming })
350
+ const ctx = { store, user, person, device, bearer, now, site, apiUrl: api, mailer, log, limit: limitInvites, limitSend: limitInviteSend, limitTokens, limitStarts, spendResume, resumeWindowMs: RESUME_WINDOW_MS, limitJoin, agentAuth, reportKey, limitReports, relaySecret, files, maxFileBytes, workspaceQuotaBytes, maxWorkspaceFiles }
351
+ // GitHub's webhook secret: set on the app by this API at start (github-app.js), kept in the store when the environment gives none.
352
+ const hookSecret = { value: (github && github.webhookSecret) || '' }
353
+ const githubReady = (async () => {
354
+ if (!github) return
355
+ const app = githubApp(github, { fetch: githubFetch, now })
356
+ await ensureWebhookSecret({ cfg: github, app, store, hook: hookSecret, log })
357
+ await redeliverFailed({ app, log })
358
+ })().catch((err) => log(`github at start: ${err.message}`))
359
+ // A person's AI on its own cloud machine (routes/ai-vm.js): off (503) without Fly set up.
360
+ const aiVm = aiVmRoutes({ ...ctx, relayUrl, fly, waitMs: aiVmWaitMs })
361
+ routes.push(...aiVm.routes)
362
+ // A team's git rule or who is on a team changed: the relay asks its sessions' rules again now (relay /github/policy).
363
+ const policyChanged = () => {
364
+ if (!relay || !relaySecret) return
365
+ cluster.tellRelays('/github/policy', {}).catch((err) => log(`git rules to relay: ${err.message}`))
366
+ }
367
+ routes.push(...orgRoutes(ctx), ...memberRoutes(ctx), ...teamRoutes({ ...ctx, policyChanged }), ...inviteRoutes(ctx), ...agentRoutes(ctx), ...agentInviteRoutes({ ...ctx, workspaces }), ...joinRoutes(ctx), ...relayRoutes(ctx), ...githubRoutes({ ...ctx, github, githubFetch, relayHttp: relay, relayFetch: githubFetch, tellRelays: (path, body) => cluster.tellRelays(path, body), hookSecret, workspaces }), ...relayClusterRoutes({ ...ctx, cluster }), ...sessionRoutes(ctx), ...accessTypeRoutes(ctx), ...grantRoutes({ ...ctx, workspaces }), ...sessionInviteRoutes({ ...ctx, workspaces }), ...issueRoutes(ctx))
368
+ // Always routed: with the flag off each answers a plain 404 of its own, so the app's
369
+ // check at every launch isn't filed as a missing route.
370
+ // Only these take a hosted agent's pass (workspaceReach), so they alone get the pass key.
371
+ routes.push(...workspaceRoutes({ ...ctx, workspaces, passKey }), ...workspaceInviteRoutes({ ...ctx, workspaces, passKey }), ...workspaceAgentRoutes({ ...ctx, workspaces, passKey, relayUrl, webhookFetch, webhookLookup, allowLocalWebhooks, trackDelivery, limitAnnounce }))
372
+ const wsFiles = workspaceFileRoutes({ ...ctx, workspaces, passKey })
373
+ routes.push(...wsFiles.routes)
374
+
375
+ async function openLink (code) {
376
+ const userCode = normalizeUserCode(code)
377
+ const link = userCode && await store.linkByUserCode(userCode)
378
+ if (!link) throw new HttpError(404, 'no such code')
379
+ if (link.status !== 'pending' || link.expiresAt < now()) throw new HttpError(410, 'this code has expired or was already used')
380
+ return link
381
+ }
382
+
383
+ // The API's own trouble, straight into the store: no-route 404s, crashes (5xx) and
384
+ // slow requests of any status; the 4xx a route throws on purpose (including a
385
+ // deliberate 404, like an expired invite or an org you've left) is only kept when
386
+ // it was slow, and then as `slow`, never as an error. Never awaited by the request,
387
+ // and a failure to record is only logged — nothing here may escape and disturb an
388
+ // already-sent reply.
389
+ function recordOwn ({ method, pathname, status, startedAt, message, noRoute }) {
390
+ try {
391
+ const durationMs = now() - startedAt
392
+ const slow = durationMs > slowMs
393
+ const crash = status >= 500
394
+ const http404 = status === 404 && noRoute
395
+ if (!crash && !http404 && !slow) return
396
+ // A scanner path outside /v1/ (wp-login.php, .env, …) has no route shape worth
397
+ // keeping per-path; group every one of those under one name instead of letting
398
+ // each distinct path become its own permanent issue.
399
+ const name = http404 && !pathname.startsWith('/v1/') ? `${method} (no route)` : routeName(method, pathname)
400
+ const event = cleanEvent({
401
+ kind: http404 ? 'http404' : 'action',
402
+ name,
403
+ outcome: crash || http404 ? 'error' : 'slow',
404
+ status, durationMs, message
405
+ }, { surface: 'api', appVersion: API_VERSION, now })
406
+ Promise.resolve().then(() => store.recordEvents([event])).catch((err) => log(`issue record failed: ${err?.message || err}`))
407
+ } catch (err) { log(`issue record failed: ${err?.message || err}`) }
408
+ }
409
+
410
+ const server = http.createServer(async (req, res) => {
411
+ const startedAt = now()
412
+ // The parsed pathname (not the raw url string) decides this: it's what a route
413
+ // actually matches against, so "/v1/../v1/join/x" counts as a join link too.
414
+ let pathname
415
+ try { pathname = new URL(req.url, 'http://x').pathname } catch { pathname = '' }
416
+ // Join links are secrets in a URL: never cache them, and ask crawlers not to index them.
417
+ const extra = pathname.startsWith('/v1/join/') ? { 'x-robots-tag': 'noindex' } : {}
418
+ const send = (status, data, type = 'application/json', message = '', noRoute = false) => {
419
+ res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store', ...extra, ...cors(req) })
420
+ res.end(type === 'application/json' ? JSON.stringify(data) : data)
421
+ recordOwn({ method: req.method, pathname, status, startedAt, message, noRoute })
422
+ }
423
+ if (req.method === 'OPTIONS') {
424
+ res.writeHead(204, { 'cache-control': 'no-store', ...extra, ...cors(req), 'access-control-allow-methods': 'GET,POST,PUT,PATCH,DELETE', 'access-control-allow-headers': 'authorization,content-type', 'access-control-max-age': '600' })
425
+ return res.end()
426
+ }
427
+ try {
428
+ if (pathname.startsWith('/v1/file-data/') && files instanceof DiskStore) return await serveFileData(req, res, send, pathname.slice('/v1/file-data/'.length))
429
+ if (pathname === '/mcp') return await proxyMcp(req, res, send)
430
+ const url = new URL(req.url, 'http://x')
431
+ const route = routes.find(([m, re]) => m === req.method && re.test(url.pathname))
432
+ if (!route) throw Object.assign(new HttpError(404, 'not found'), { noRoute: true })
433
+ // A route may take a bigger body than usual (the relay's presence reports): route[3].maxBody.
434
+ // A route may want the bytes as sent (route[3].raw: GitHub's webhooks, signed over the raw body).
435
+ const body = route[3]?.raw ? await readRaw(req, route[3]?.maxBody || MAX_BODY) : ['POST', 'PUT', 'PATCH'].includes(req.method) ? await readJson(req, route[3]?.maxBody || MAX_BODY) : {}
436
+ const out = await route[2](req, body, url.pathname.match(route[1]).slice(1).map(decodePart))
437
+ if (out instanceof Raw) send(out.status, out.body, out.type)
438
+ else if (Array.isArray(out)) send(out[0], out[1])
439
+ else send(200, out)
440
+ } catch (err) {
441
+ // A unique index said no. Agents share one index on their public key; everything
442
+ // else that's unique (a taken team or role name) gets the generic message.
443
+ if (err?.code === '23505') {
444
+ const detail = `${err.constraint || ''} ${err.message || ''} ${err.details || ''}`
445
+ return send(409, { error: detail.includes('public_key') ? 'That public key already belongs to an agent.' : 'that name is already taken' })
446
+ }
447
+ // A foreign-key check said no (something from another org, or still referenced): a conflict, not a crash.
448
+ if (err?.code === '23503') return send(409, { error: 'that is still in use' })
449
+ // Supabase errors are plain objects, so fall back to their JSON.
450
+ if (!(err instanceof HttpError)) log(`api error: ${err?.stack || err?.message || JSON.stringify(err)}`)
451
+ send(err.status || 500, { error: err instanceof HttpError ? err.message : 'internal error' }, 'application/json', err instanceof HttpError ? err.message : String(err?.message || err?.stack || JSON.stringify(err) || 'error'), !!err.noRoute)
452
+ }
453
+ })
454
+
455
+ // Hosted agents' MCP: the agent's access key signs it in here; the relay gets the same
456
+ // request with a pass for the agent, and its answer comes straight back. Stateless on
457
+ // both sides, so each request stands alone. The pass is for the room the agent is joining
458
+ // (named in quilt_join_session) or is in (the relay says which in x-quilt-room), so it
459
+ // carries the agent's grant there.
460
+ const mintedPasses = new Map() // `${agent id}\n${room}` -> { pass, exp }
461
+ const agentRooms = new Map() // agent id -> the rooms the relay last said it is in (x-quilt-rooms)
462
+ const forget = (map) => { if (map.size > maxStartKeys) map.delete(map.keys().next().value) }
463
+ async function proxyMcp (req, res, send) {
464
+ needPassKey()
465
+ if (!isAgentKey(bearer(req))) throw new HttpError(401, 'send your agent access key (or app key) as "Authorization: Bearer <key>"')
466
+ const { agent } = await agentAuth.agentFromRequest(req)
467
+ limitMcp(agent.id)
468
+ const body = ['POST', 'PUT'].includes(req.method) ? await readRaw(req, workspaces ? MAX_MCP_BODY_WORKSPACES : MAX_MCP_BODY) : undefined
469
+ const holder = await agentHolder(agent)
470
+ const passFor = async (room) => {
471
+ const cacheKey = `${agent.id}\n${room}`
472
+ let minted = mintedPasses.get(cacheKey)
473
+ if (!minted || minted.exp - now() < PASS_REUSE_MARGIN_MS) {
474
+ const { pass, expiresAt } = await mintPass(holder, room)
475
+ minted = { pass, exp: expiresAt }
476
+ mintedPasses.set(cacheKey, minted)
477
+ if (mintedPasses.size > maxStartKeys) for (const [k, v] of mintedPasses) if (v.exp <= now()) mintedPasses.delete(k)
478
+ forget(mintedPasses)
479
+ }
480
+ return minted.pass
481
+ }
482
+ const ask = async (room) => {
483
+ const headers = { 'x-quilt-pass': await passFor(room) }
484
+ for (const h of ['accept', 'content-type', 'mcp-protocol-version', 'mcp-session-id', 'last-event-id']) if (req.headers[h]) headers[h] = String(req.headers[h])
485
+ try {
486
+ return await fetch(`${relay}/mcp`, { method: req.method, headers, body, signal: AbortSignal.timeout(MCP_TIMEOUT_MS) })
487
+ } catch (err) {
488
+ log(`mcp relay error: ${err.message}`)
489
+ throw new HttpError(502, 'the session relay did not answer; try again in a moment')
490
+ }
491
+ }
492
+ // The room this call is about: the invite it joins, the `session` it names, else the agent's only room.
493
+ const known = agentRooms.get(agent.id) || []
494
+ const room = joiningRoom(body) || sessionArg(body) || (known.length === 1 ? known[0] : '')
495
+ let upstream = await ask(room)
496
+ // The relay needs a pass for the room the agent is in (this server forgot it, after a
497
+ // restart): ask again, once, with one for the room it names. Tool calls are only refused
498
+ // there, never carried out, so the same call is safe to send again.
499
+ const named = upstream.headers.get('x-quilt-room') || ''
500
+ if (upstream.headers.get('x-quilt-retry') === 'room-pass' && ROOM.test(named) && named !== room) {
501
+ await upstream.arrayBuffer().catch(() => {})
502
+ upstream = await ask(named)
503
+ }
504
+ if (upstream.ok) {
505
+ const listed = String(upstream.headers.get('x-quilt-rooms') || upstream.headers.get('x-quilt-room') || '').split(',').map((r) => r.trim()).filter((r) => ROOM.test(r))
506
+ if (listed.length) { agentRooms.set(agent.id, listed); forget(agentRooms) } else agentRooms.delete(agent.id)
507
+ }
508
+ const type = upstream.headers.get('content-type') || 'application/json'
509
+ const out = Buffer.from(await upstream.arrayBuffer())
510
+ res.writeHead(upstream.status, { 'content-type': type, 'cache-control': 'no-store' })
511
+ res.end(out)
512
+ }
513
+
514
+ // The disk store's links: PUT streams an upload to the API's disk within the signed size; GET streams it back.
515
+ async function serveFileData (req, res, send, key) {
516
+ const q = new URL(req.url, 'http://x').searchParams
517
+ const method = req.method
518
+ if (!['PUT', 'GET'].includes(method) || q.get('m') !== method) return send(405, { error: 'method not allowed' })
519
+ const size = method === 'PUT' ? Number(q.get('n')) : undefined
520
+ const name = q.get('name') || ''
521
+ const type = q.get('type') || ''
522
+ if (!files.verify(key, method, q.get('exp'), q.get('sig'), size, name, type)) return send(403, { error: 'this link is not valid' })
523
+ const file = files.file(key)
524
+ if (method === 'PUT') {
525
+ // Like Supabase's upsert: false, an object that has landed is never replaced.
526
+ if (fs.existsSync(file)) return send(409, { error: 'already stored' })
527
+ const tmp = `${file}.part`
528
+ let out = null
529
+ let failed = null
530
+ try {
531
+ fs.mkdirSync(path.dirname(file), { recursive: true })
532
+ out = fs.createWriteStream(tmp)
533
+ // Without this, a write failure (disk full, permission denied) is an
534
+ // unhandled 'error' event on the stream, which crashes the whole process.
535
+ out.on('error', (err) => { failed = failed || err })
536
+ let got = 0
537
+ for await (const chunk of req) {
538
+ if (failed) throw failed
539
+ got += chunk.length
540
+ if (got > size) throw new HttpError(413, 'more bytes than the link allows')
541
+ if (!out.write(chunk)) await new Promise((resolve) => out.once('drain', resolve))
542
+ if (failed) throw failed
543
+ }
544
+ if (failed) throw failed
545
+ await new Promise((resolve, reject) => out.end((err) => (err || failed ? reject(err || failed) : resolve())))
546
+ fs.renameSync(tmp, file)
547
+ return send(200, { ok: true })
548
+ } catch (err) {
549
+ if (out) out.destroy()
550
+ fs.rmSync(tmp, { force: true })
551
+ return send(err.status || 500, { error: err.message })
552
+ }
553
+ }
554
+ let stat
555
+ try { stat = fs.statSync(file) } catch { return send(404, { error: 'not found' }) }
556
+ const headers = { 'content-type': type || 'application/octet-stream', 'content-length': stat.size, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...cors(req) }
557
+ if (name) headers['content-disposition'] = contentDisposition('attachment', name)
558
+ res.writeHead(200, headers)
559
+ // A read that fails once the headers are out can only cut the response short.
560
+ await pipeline(fs.createReadStream(file), res).catch(() => res.destroy())
561
+ }
562
+
563
+ // Only the website may call the API from a browser.
564
+ function cors (req) {
565
+ return site && req.headers.origin === site ? { 'access-control-allow-origin': site, vary: 'origin' } : {}
566
+ }
567
+
568
+ // Events are the detail (kept 30 days); issues are the summary people read (kept
569
+ // 90 days, so slower-moving problems don't vanish while their events still would).
570
+ async function pruneNow () {
571
+ try { await store.pruneEvents(now() - keepEventsMs) } catch (err) { log(`issue prune failed: ${err?.message || err}`) }
572
+ try { await store.pruneIssues(now() - keepIssuesMs) } catch (err) { log(`issue prune failed: ${err?.message || err}`) }
573
+ // Workspace files: uploads that never landed, and deleted files past their 30 days
574
+ // (only with the flag on: before then the tables may not exist yet).
575
+ if (workspaces) try { await wsFiles.sweep() } catch (err) { log(`file sweep failed: ${err?.message || err}`) }
576
+ }
577
+ const prune = setInterval(() => { pruneNow() }, pruneEveryMs)
578
+ prune.unref()
579
+ // Also soon after start, since every deploy restarts the hourly clock.
580
+ const firstPrune = setTimeout(() => { pruneNow() }, pruneStartMs)
581
+ firstPrune.unref()
582
+
583
+ /** Waits for every webhook delivery started so far (and any they start) to finish. */
584
+ async function flushWebhooks () {
585
+ while (deliveries.size) await Promise.allSettled([...deliveries])
586
+ }
587
+
588
+ return new Promise((resolve) => server.listen(port, host, () => {
589
+ const p = server.address().port
590
+ resolve({ port: p, url: `http://${host}:${p}`, cluster, close: () => { clearInterval(prune); clearTimeout(firstPrune); return new Promise((r) => server.close(r)) }, startKeys: () => limitStarts.size(), sweepFiles: (at) => wsFiles.sweep(at), flushWebhooks, githubReady })
591
+ }))
592
+ }
593
+
594
+ // Which room a hosted agent's MCP request is about: shared with the relay (src/mcp-room.js).
595
+ export { joiningRoom, sessionArg } from '../mcp-room.js'
596
+
597
+ function decodePart (s) {
598
+ try { return decodeURIComponent(s) } catch { throw new HttpError(400, 'bad path') }
599
+ }
600
+
601
+ function readRaw (req, max) {
602
+ return new Promise((resolve, reject) => {
603
+ let size = 0; const chunks = []
604
+ req.on('data', (c) => { size += c.length; if (size > max) { req.destroy(); reject(new HttpError(413, 'too large')) } else chunks.push(c) })
605
+ req.on('end', () => resolve(Buffer.concat(chunks)))
606
+ req.on('error', reject)
607
+ })
608
+ }
609
+
610
+ function readJson (req, limit) {
611
+ return new Promise((resolve, reject) => {
612
+ let size = 0; const chunks = []
613
+ req.on('data', (c) => { size += c.length; if (size > limit) { req.destroy(); reject(new HttpError(413, 'too large')) } else chunks.push(c) })
614
+ req.on('end', () => {
615
+ if (!chunks.length) return resolve({})
616
+ let body
617
+ try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { return reject(new HttpError(400, 'bad json')) }
618
+ // Handlers read fields off the body; null, arrays and scalars carry none.
619
+ resolve(body && typeof body === 'object' && !Array.isArray(body) ? body : {})
620
+ })
621
+ req.on('error', reject)
622
+ })
623
+ }
@@ -0,0 +1,20 @@
1
+ // Org addresses: /org/<slug>. Lowercase letters, digits and single hyphens.
2
+ // "personal" is the space switcher's name for your own space; "new" and
3
+ // "discover" are words routes might want.
4
+ const RESERVED = new Set(['personal', 'new', 'discover'])
5
+
6
+ export function slugify (name) {
7
+ const s = String(name || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
8
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/, '')
9
+ if (!s) return 'org'
10
+ return RESERVED.has(s) ? `${s}-org` : s
11
+ }
12
+
13
+ /** The first of base, base-2, base-3, ... that `taken` says is free. */
14
+ export async function uniqueSlug (name, taken) {
15
+ const base = slugify(name)
16
+ for (let n = 1; ; n++) {
17
+ const slug = n === 1 ? base : `${base}-${n}`
18
+ if (!await taken(slug)) return slug
19
+ }
20
+ }