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,47 @@
1
+ // src/api/routes/issues.js
2
+ // Issue reports from the desktop app and the website: errors, 404s, crashes and how long
3
+ // actions took. Who is reporting decides the surface and who the events belong to; the
4
+ // body never does.
5
+ import crypto from 'node:crypto'
6
+ import { HttpError, UUID } from '../http.js'
7
+ import { cleanEvent, MAX_BATCH } from '../issues.js'
8
+
9
+ // Compares by byte length, not by UTF-16 string length: a header with the right character
10
+ // count but non-ASCII (or otherwise multi-byte) bytes would otherwise reach
11
+ // timingSafeEqual with mismatched buffer lengths, which throws rather than answering false.
12
+ const sameKey = (a, b) => {
13
+ const bufA = Buffer.from(a); const bufB = Buffer.from(b)
14
+ return bufA.length === bufB.length && crypto.timingSafeEqual(bufA, bufB)
15
+ }
16
+
17
+ export function issueRoutes ({ store, device, bearer, now, reportKey = '', limitReports }) {
18
+ /** Who is reporting: { surface, userId, deviceId }. */
19
+ async function reporter (req, body) {
20
+ const surface = String(body.surface || '')
21
+ if (bearer(req).startsWith('qd_')) {
22
+ const d = await device(req)
23
+ if (surface !== 'app') throw new HttpError(400, 'a computer reports for the app')
24
+ return { surface, userId: d.userId, deviceId: d.id }
25
+ }
26
+ const key = String(req.headers['x-quilt-report-key'] || '')
27
+ if (key) {
28
+ if (!reportKey || !sameKey(key, reportKey)) throw new HttpError(401, 'bad report key')
29
+ if (surface !== 'web') throw new HttpError(400, 'the website reports for the web')
30
+ return { surface, userId: UUID.test(String(body.userId || '')) ? String(body.userId) : null, deviceId: null }
31
+ }
32
+ // Nobody signed in: the app before sign-in (a failed sign-in is worth knowing about).
33
+ if (surface !== 'app') throw new HttpError(401, 'sign in first')
34
+ limitReports(req)
35
+ return { surface, userId: null, deviceId: null }
36
+ }
37
+
38
+ return [
39
+ ['POST', /^\/v1\/issues$/, async (req, body) => {
40
+ const who = await reporter(req, body)
41
+ if (!Array.isArray(body.events) || body.events.length < 1 || body.events.length > MAX_BATCH) throw new HttpError(400, `events must hold 1 to ${MAX_BATCH} items`)
42
+ const events = body.events.map((raw) => cleanEvent(raw, { ...who, appVersion: body.appVersion, platform: body.platform, now }))
43
+ const recorded = await store.recordEvents(events)
44
+ return { ok: true, recorded }
45
+ }]
46
+ ]
47
+ }
@@ -0,0 +1,167 @@
1
+ // An AI opening an agent invite link. A GET without a name only explains, so
2
+ // link previews and scanners never use the invite up; a POST, or a GET with
3
+ // name, provider and type, uses it once and hands back the agent's first keys.
4
+ // An agent that already joined once gives its agentId (public, like a person's id)
5
+ // and comes back as itself instead of as a new agent with the same name.
6
+ import { HttpError, Raw, cleanName, stripInvisible } from '../http.js'
7
+ import { hashToken } from '../tokens.js'
8
+ import { parsePublicKey } from '../../identity.js'
9
+ import { joinInstructions, joinNext } from '../join-text.js'
10
+ import { inviteStatus } from './agent-invites.js'
11
+
12
+ const MAX_DESCRIPTION = 180
13
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
14
+ const NEW_AGENT = 'leave agentId out to join as a new agent'
15
+ const GONE = {
16
+ used: 'this invite was already used; ask for a new one',
17
+ expired: 'this invite has expired; ask for a new one',
18
+ cancelled: 'this invite was cancelled; ask for a new one'
19
+ }
20
+
21
+ export function joinRoutes ({ store, now, apiUrl, limitJoin, agentAuth, log = () => {} }) {
22
+ const inviteFor = async (token) => (String(token).startsWith('qj_') ? store.agentInviteByToken(hashToken(token)) : null)
23
+ const statusOf = (invite) => (invite ? inviteStatus(invite, now()) : 'unknown')
24
+
25
+ // Required as an actual string, never a stray number or object a loose client sent,
26
+ // and never the literal <placeholder> text from the join instructions.
27
+ function field (value, name, fallback) {
28
+ if (value === undefined && fallback !== undefined) value = fallback
29
+ if (typeof value !== 'string') throw new HttpError(400, `${name} must be a string`)
30
+ if (value.includes('<') || value.includes('>')) throw new HttpError(400, `${name} can't contain < or >`)
31
+ return value
32
+ }
33
+
34
+ // The agent's profile, checked in full before the invite is touched.
35
+ function profile (src) {
36
+ const publicKey = src.publicKey == null || src.publicKey === '' ? null : String(src.publicKey)
37
+ if (publicKey && !parsePublicKey(publicKey)) throw new HttpError(400, 'publicKey must be an Ed25519 key (spki, base64url)')
38
+ return {
39
+ name: cleanName(field(src.name, 'name'), 40, 'give your name (up to 40 characters)'),
40
+ provider: cleanName(field(src.provider, 'provider'), 40, 'give your provider, e.g. Anthropic, OpenAI or Cursor'),
41
+ type: cleanName(field(src.type, 'type'), 40, 'give your type, e.g. coding agent'),
42
+ description: stripInvisible(field(src.description, 'description', '')).slice(0, MAX_DESCRIPTION).join('').trim(),
43
+ publicKey
44
+ }
45
+ }
46
+
47
+ // The agent coming back, when the join names one (agentId is optional). The invite is
48
+ // what lets it in: the id only has to name an agent of the invite's person or org.
49
+ async function returning (src, invite) {
50
+ const id = src.agentId
51
+ if (id == null || id === '') return null
52
+ if (typeof id !== 'string' || !UUID.test(id)) throw new HttpError(400, `agentId must be the id Quilt gave you when you first joined; ${NEW_AGENT}`)
53
+ const agent = await store.agentById(id.toLowerCase())
54
+ if (!agent) throw new HttpError(404, `no agent has that agentId; ${NEW_AGENT}`)
55
+ const home = invite.orgId ? agent.orgId === invite.orgId : agent.ownerUserId === invite.ownerUserId
56
+ if (!home) throw new HttpError(403, `that agent belongs to someone else, and this invite can't bring it back; ${NEW_AGENT}`)
57
+ return agent
58
+ }
59
+
60
+ // A returning org agent gets the invite's role and teams; teams it has and the invite
61
+ // doesn't name are left as they are.
62
+ async function rejoinOrg (invite, agent) {
63
+ let m = await store.memberByAgent(invite.orgId, agent.id)
64
+ if (!m) m = await store.addAgentMember({ orgId: invite.orgId, agentId: agent.id, roleId: invite.roleId })
65
+ else if (invite.roleId && m.roleId !== invite.roleId) await store.setMemberRole(m.id, invite.roleId)
66
+ const mine = new Set((await store.teamsOfMember(m.id)).map((x) => x.teamId))
67
+ for (const x of invite.teams) {
68
+ if (!await store.teamById(invite.orgId, x.teamId)) continue
69
+ if (mine.has(x.teamId)) await store.setTeamAccess(x.teamId, m.id, x.access, x.scopes)
70
+ else await store.addTeamMember({ teamId: x.teamId, memberId: m.id, access: x.access, scopes: x.scopes })
71
+ }
72
+ }
73
+
74
+ const reply = (agent, keys, resumeKey, rejoined) => ({
75
+ ...keys,
76
+ resumeKey,
77
+ rejoined,
78
+ api: apiUrl,
79
+ refresh: `${apiUrl}/v1/agents/token`,
80
+ resume: `${apiUrl}/v1/agents/resume`,
81
+ mcp: `${apiUrl}/mcp`,
82
+ next: joinNext({ name: agent.name, agentId: agent.id, apiUrl, hasKey: !!agent.publicKey, rejoined })
83
+ })
84
+
85
+ async function join (token, src) {
86
+ const invite = await inviteFor(token)
87
+ const status = statusOf(invite)
88
+ if (status === 'unknown') throw new HttpError(404, "this invite link isn't valid")
89
+ if (status !== 'waiting') throw new HttpError(410, GONE[status])
90
+ // Everything the agent sent is checked first, so a typo never burns the invite.
91
+ const p = profile(src)
92
+ const back = await returning(src, invite)
93
+ const owner = p.publicKey && await store.agentByPublicKey(p.publicKey)
94
+ if (owner && owner.id !== back?.id) throw new HttpError(409, 'that publicKey already belongs to an agent')
95
+ // Claim first, so two joins racing on one link can't both make an agent.
96
+ if (!await store.claimAgentInvite(invite.id)) throw new HttpError(410, GONE.used)
97
+ const { resumeKey, resumeHash } = agentAuth.newResumeKey()
98
+ if (back) {
99
+ // Its keys from before keep working until they run out, like a person signing in
100
+ // on a second computer; its old resume key is replaced by this one.
101
+ try {
102
+ const agent = await store.rejoinAgent(back.id, { ...p, resumeHash })
103
+ if (invite.orgId) await rejoinOrg(invite, agent)
104
+ await store.setInviteAgent(invite.id, agent.id, true)
105
+ return reply(agent, await agentAuth.mintKeys(agent.id), resumeKey, true)
106
+ } catch (err) {
107
+ await store.releaseAgentInvite(invite.id).catch(() => {})
108
+ throw err
109
+ }
110
+ }
111
+ let agent
112
+ try {
113
+ agent = await store.createAgent({ ...p, resumeHash, ownerUserId: invite.ownerUserId, orgId: invite.orgId, invitedBy: invite.createdBy })
114
+ if (invite.orgId) {
115
+ const m = await store.addAgentMember({ orgId: invite.orgId, agentId: agent.id, roleId: invite.roleId })
116
+ for (const x of invite.teams) {
117
+ // A team deleted since the invite was made is skipped.
118
+ if (await store.teamById(invite.orgId, x.teamId)) await store.addTeamMember({ teamId: x.teamId, memberId: m.id, access: x.access, scopes: x.scopes })
119
+ }
120
+ }
121
+ // A workspace's invite (workspace-agents.js) makes the agent one of its members.
122
+ if (invite.workspaceId) {
123
+ await store.putWorkspaceMember({ workspaceId: invite.workspaceId, account: `agent:${agent.id}`, access: invite.workspaceAccess || 'edit', sessions: invite.workspaceSessions || 'invited', addedBy: invite.createdBy })
124
+ }
125
+ // A global agent's invite (workspaces on when it was made) places it in all its owner's
126
+ // workspaces, invited to every session there. Deleting the agent below takes it too.
127
+ if (invite.global) {
128
+ await store.putAgentPlacement({ agentId: agent.id, reach: 'all', workspaceIds: [], sessions: 'all', access: 'edit', scopes: [], updatedBy: `person:${invite.createdBy}` })
129
+ }
130
+ await store.setInviteAgent(invite.id, agent.id)
131
+ return reply(agent, await agentAuth.mintKeys(agent.id), resumeKey, false)
132
+ } catch (err) {
133
+ // Undo the half-made agent and reopen the link, so the AI can simply try again.
134
+ // But if the agent couldn't be deleted, it may still be half-wired into the org
135
+ // (team membership, etc): leave the invite used rather than hand out a link that
136
+ // would make a second, equally broken agent on top of the first.
137
+ let deleted = !agent
138
+ // Member rows name the agent as text, so deleting it leaves them: remove this one first.
139
+ if (agent && invite.workspaceId) await store.removeWorkspaceMember(invite.workspaceId, `agent:${agent.id}`).catch(() => {})
140
+ if (agent) {
141
+ try { await store.deleteAgent(agent.id); deleted = true } catch (delErr) {
142
+ log(`join rollback: couldn't delete half-made agent ${agent.id}: ${delErr?.stack || delErr?.message || delErr}`)
143
+ }
144
+ }
145
+ if (deleted) await store.releaseAgentInvite(invite.id).catch(() => {})
146
+ throw err
147
+ }
148
+ }
149
+
150
+ return [
151
+ ['POST', /^\/v1\/join\/([^/]+)$/, async (req, body, [token]) => {
152
+ limitJoin(req)
153
+ return join(token, body)
154
+ }],
155
+
156
+ ['GET', /^\/v1\/join\/([^/]+)$/, async (req, body, [token]) => {
157
+ limitJoin(req)
158
+ const q = new URL(req.url, 'http://x').searchParams
159
+ // Only a GET that names the agent uses the invite (agentId alone doesn't).
160
+ if (['name', 'provider', 'type'].some((k) => q.has(k))) return join(token, Object.fromEntries(q))
161
+ const invite = await inviteFor(token)
162
+ const status = statusOf(invite)
163
+ const text = joinInstructions({ link: `${apiUrl}/v1/join/${encodeURIComponent(token)}`, apiUrl, status, expiresAt: invite?.expiresAt })
164
+ return new Raw(status === 'waiting' ? 200 : status === 'unknown' ? 404 : 410, text)
165
+ }]
166
+ ]
167
+ }
@@ -0,0 +1,110 @@
1
+ // An org's people and agents: the member list, changing a role, removing or leaving.
2
+ import { HttpError, needId } from '../http.js'
3
+ import { orgAccess } from '../org-access.js'
4
+
5
+ export function memberRoutes ({ store, user }) {
6
+ const orgFor = async (req, slug) => { const u = await user(req); return { u, ...(await orgAccess(store, u.userId, slug)) } }
7
+
8
+ async function target (a, id) {
9
+ const m = await store.memberById(a.org.id, needId(id, 'member'))
10
+ if (!m) throw new HttpError(404, 'no such member')
11
+ if (m.userId && m.userId === a.org.ownerId) throw new HttpError(403, 'the owner only changes through a transfer')
12
+ return m
13
+ }
14
+
15
+ // You can't act on someone (or an agent) whose role holds checkboxes you don't.
16
+ async function outranks (a, m) {
17
+ const r = m.roleId && await store.roleById(a.org.id, m.roleId)
18
+ if (!a.covers(r?.grants)) throw new HttpError(403, m.agentId ? "this agent has permissions you don't have" : "this person has permissions you don't have")
19
+ }
20
+
21
+ return [
22
+ ['GET', /^\/v1\/orgs\/([^/]+)\/members$/, async (req, body, [slug]) => {
23
+ const a = await orgFor(req, slug)
24
+ // One list: people for Members: Read, agents for Agents: Read.
25
+ const seePeople = a.can('members', 'r')
26
+ const seeAgents = a.can('agents', 'r')
27
+ if (!seePeople && !seeAgents) a.need('members', 'r')
28
+ const [everyone, roles] = await Promise.all([store.listMembers(a.org.id), store.listRoles(a.org.id)])
29
+ const members = everyone.filter((m) => (m.agentId ? seeAgents : seePeople))
30
+ const roleName = new Map(roles.map((r) => [r.id, r.name]))
31
+ // Team names only for people allowed to see who's in each team.
32
+ const teamsOf = new Map()
33
+ if (a.can('team_members', 'r')) {
34
+ for (const team of await store.listTeams(a.org.id)) {
35
+ for (const tm of await store.listTeamMembers(team.id)) {
36
+ teamsOf.set(tm.memberId, [...(teamsOf.get(tm.memberId) || []), { id: team.id, name: team.name, access: tm.access, scopes: tm.scopes || [] }])
37
+ }
38
+ }
39
+ }
40
+ const emails = await Promise.all(members.map((m) => (m.userId ? store.userEmail(m.userId) : null)))
41
+ return {
42
+ members: members.map((m, i) => ({
43
+ id: m.id,
44
+ kind: m.agentId ? 'agent' : 'person',
45
+ userId: m.userId ?? null,
46
+ agentId: m.agentId ?? null,
47
+ name: m.name,
48
+ provider: m.provider ?? null,
49
+ type: m.type ?? null,
50
+ // Only for an agent; whether it has a key, never the key itself.
51
+ // Agents always can: with a key from a computer running Quilt too, without one through the hosted MCP.
52
+ canJoinSessions: m.agentId ? true : null,
53
+ hosted: m.agentId ? !m.publicKey : null,
54
+ email: emails[i]?.email || '',
55
+ roleId: m.roleId,
56
+ role: roleName.get(m.roleId) || null,
57
+ isOwner: !!m.userId && m.userId === a.org.ownerId,
58
+ isYou: !!m.userId && m.userId === a.u.userId,
59
+ joinedAt: m.joinedAt,
60
+ teams: teamsOf.get(m.id) || []
61
+ }))
62
+ }
63
+ }],
64
+
65
+ ['PUT', /^\/v1\/orgs\/([^/]+)\/members\/([^/]+)$/, async (req, body, [slug, id]) => {
66
+ const a = await orgFor(req, slug)
67
+ const m = await target(a, id)
68
+ if (m.agentId) {
69
+ // An agent's role is Agents: Update, and it may have none (team access only).
70
+ a.need('agents', 'u')
71
+ await outranks(a, m)
72
+ // roleId must be given explicitly: a body that omits it is never read as "clear
73
+ // the role", so a partial PUT (e.g. missing a field a future client meant to send)
74
+ // can't silently wipe one.
75
+ if (!('roleId' in body)) throw new HttpError(400, 'roleId is required; use null to clear the role')
76
+ const role = body.roleId ? await a.assignable(body.roleId) : null
77
+ const saved = await store.setMemberRole(m.id, role ? role.id : null)
78
+ return { member: { id: saved.id, roleId: saved.roleId } }
79
+ }
80
+ // Assigning a person's role is Members: Update together with Roles: Update.
81
+ a.need('members', 'u')
82
+ a.need('roles', 'u')
83
+ if (m.userId === a.u.userId) throw new HttpError(403, "you can't change your own role")
84
+ await outranks(a, m)
85
+ const role = await a.assignable(body.roleId || 'missing')
86
+ const saved = await store.setMemberRole(m.id, role.id)
87
+ return { member: { id: saved.id, roleId: saved.roleId } }
88
+ }],
89
+
90
+ ['DELETE', /^\/v1\/orgs\/([^/]+)\/members\/([^/]+)$/, async (req, body, [slug, id]) => {
91
+ const a = await orgFor(req, slug)
92
+ const m = await target(a, id)
93
+ if (m.agentId) {
94
+ // An org agent has nowhere else to be, so removing it revokes it.
95
+ a.need('agents', 'd')
96
+ await outranks(a, m)
97
+ await store.revokeAgent(m.agentId)
98
+ await store.removeMember(m.id)
99
+ return { ok: true }
100
+ }
101
+ // Anyone may leave; removing someone else needs Members: Delete.
102
+ if (m.userId !== a.u.userId) {
103
+ a.need('members', 'd')
104
+ await outranks(a, m)
105
+ }
106
+ await store.removeMember(m.id)
107
+ return { ok: true }
108
+ }]
109
+ ]
110
+ }
@@ -0,0 +1,177 @@
1
+ // Orgs: creating one, its settings and domain rule, ownership, and its roles.
2
+ import { HttpError, needId, cleanName } from '../http.js'
3
+ import { orgAccess } from '../org-access.js'
4
+ import { BUILTIN, normalizeGrants } from '../permissions.js'
5
+ import { emailDomain, isDomain, isPublicDomain } from '../domains.js'
6
+ import { uniqueSlug } from '../slugs.js'
7
+
8
+ const ROLE_ORDER = { owner: 0, admin: 1, member: 2 }
9
+ const RESERVED_ROLE_NAMES = new Set(['owner', 'admin', 'member'])
10
+ const checkNotReserved = (name) => {
11
+ if (RESERVED_ROLE_NAMES.has(name.toLowerCase())) throw new HttpError(400, 'That name is reserved for a built-in role.')
12
+ }
13
+ const sortRoles = (roles) => [...roles].sort((a, b) => (ROLE_ORDER[a.builtin] ?? 3) - (ROLE_ORDER[b.builtin] ?? 3) || a.name.localeCompare(b.name))
14
+ const orgView = (o) => ({ id: o.id, name: o.name, slug: o.slug, domain: o.domain, domainRequests: o.domainRequests, createdAt: o.createdAt })
15
+ const roleView = (r) => ({ id: r.id, name: r.name, builtin: r.builtin, grants: normalizeGrants(r.grants), createdAt: r.createdAt })
16
+
17
+ export function orgRoutes ({ store, user, person }) {
18
+ const orgFor = async (req, slug) => { const u = await user(req); return { u, ...(await orgAccess(store, u.userId, slug)) } }
19
+
20
+ // Two orgs made at once can pick the same slug; the unique index decides and we try the next.
21
+ // first: true (a team sign-up's first dashboard visit) asks the store to
22
+ // hand back an org the owner is already in, rather than making a second one
23
+ // when two tabs (or a double click) both raced here with no org yet.
24
+ async function createOrg (name, ownerId, first = false) {
25
+ for (let attempt = 0; ; attempt++) {
26
+ const slug = await uniqueSlug(name, async (s) => !!await store.orgBySlug(s))
27
+ try {
28
+ return await store.createOrg({ name, slug, ownerId, grants: BUILTIN, first })
29
+ } catch (err) {
30
+ if (err?.code !== '23505' || attempt >= 2) throw err
31
+ }
32
+ }
33
+ }
34
+
35
+ // Only a domain the caller has proved they get mail at, and never a public provider's.
36
+ async function ownDomain (userId, domain) {
37
+ if (!isDomain(domain)) throw new HttpError(400, "that isn't a domain")
38
+ if (isPublicDomain(domain)) throw new HttpError(400, "public email domains can't be used")
39
+ const mine = await store.userEmail(userId)
40
+ if (!mine?.confirmed || emailDomain(mine.email) !== domain) throw new HttpError(403, 'you can only use the domain of your own confirmed email')
41
+ return domain
42
+ }
43
+
44
+ async function roleIn (a, id) {
45
+ const role = await store.roleById(a.org.id, needId(id, 'role'))
46
+ if (!role) throw new HttpError(404, 'no such role')
47
+ return role
48
+ }
49
+
50
+ return [
51
+ ['GET', /^\/v1\/orgs$/, async (req) => {
52
+ // The app lists your orgs too (where a new workspace goes), so a computer's qd_ token works here.
53
+ const u = await person(req)
54
+ const orgs = await store.orgsForUser(u.userId)
55
+ return {
56
+ orgs: await Promise.all(orgs.map(async (o) => ({
57
+ ...orgView(o), isOwner: o.ownerId === u.userId, role: (await store.roleById(o.id, o.roleId))?.name || null
58
+ })))
59
+ }
60
+ }],
61
+
62
+ // Only an org account can create an org, and only when it isn't in one yet
63
+ // (first: true, checked again by createOrg itself against a race).
64
+ ['POST', /^\/v1\/orgs$/, async (req, body) => {
65
+ const u = await user(req)
66
+ if (body.first !== true || await store.profileKind(u.userId) !== 'org') {
67
+ throw new HttpError(403, 'Orgs are created by signing up as an org.')
68
+ }
69
+ return { org: orgView(await createOrg(cleanName(body.name, 80, 'give the org a name'), u.userId, true)) }
70
+ }],
71
+
72
+ ['GET', /^\/v1\/orgs\/([^/]+)\/me$/, async (req, body, [slug]) => {
73
+ const a = await orgFor(req, slug)
74
+ return {
75
+ org: { ...orgView(a.org), ownerId: a.org.ownerId },
76
+ role: a.role && { id: a.role.id, name: a.role.name, builtin: a.role.builtin },
77
+ grants: a.grants,
78
+ isOwner: a.isOwner,
79
+ memberId: a.me.id
80
+ }
81
+ }],
82
+
83
+ ['PUT', /^\/v1\/orgs\/([^/]+)$/, async (req, body, [slug]) => {
84
+ const a = await orgFor(req, slug)
85
+ a.need('org', 'u')
86
+ const patch = {}
87
+ if (body.name !== undefined) patch.name = cleanName(body.name, 80, 'give the org a name')
88
+ if (body.domain !== undefined) {
89
+ const d = body.domain == null ? '' : String(body.domain).trim().toLowerCase().replace(/\.$/, '')
90
+ // Re-saving the current domain (say, alongside a rename) doesn't need re-proving.
91
+ patch.domain = !d ? null : d === a.org.domain ? d : await ownDomain(a.u.userId, d)
92
+ }
93
+ if (body.domainRequests !== undefined) patch.domainRequests = body.domainRequests === true
94
+ if (!Object.keys(patch).length) return { org: orgView(a.org) }
95
+ const domain = 'domain' in patch ? patch.domain : a.org.domain
96
+ if (patch.domainRequests && !domain) throw new HttpError(400, 'set a domain first')
97
+ if (!domain) patch.domainRequests = false
98
+ return { org: orgView(await store.updateOrg(a.org.id, patch)) }
99
+ }],
100
+
101
+ ['DELETE', /^\/v1\/orgs\/([^/]+)$/, async (req, body, [slug]) => {
102
+ const a = await orgFor(req, slug)
103
+ a.needOwner()
104
+ await store.deleteOrg(a.org.id)
105
+ return { ok: true }
106
+ }],
107
+
108
+ ['POST', /^\/v1\/orgs\/([^/]+)\/transfer$/, async (req, body, [slug]) => {
109
+ const a = await orgFor(req, slug)
110
+ a.needOwner()
111
+ const to = await store.memberById(a.org.id, needId(body.memberId, 'member'))
112
+ if (!to?.userId) throw new HttpError(404, 'no such member')
113
+ if (to.userId === a.u.userId) throw new HttpError(400, 'you already own this org')
114
+ try {
115
+ await store.transferOrg(a.org.id, a.u.userId, to.userId)
116
+ } catch (err) {
117
+ // Defensive: the checks above should make these unreachable, but a store
118
+ // that finds otherwise should answer cleanly rather than with a 500.
119
+ if (err?.code === 'QO001') throw new HttpError(404, 'the current owner is no longer a member of this org')
120
+ if (err?.code === 'QO002') throw new HttpError(400, 'that member is not part of this org')
121
+ // Someone else already became owner between this page loading and the click.
122
+ if (err?.code === 'QO003') throw new HttpError(409, 'Ownership already changed. Reload and try again.')
123
+ throw err
124
+ }
125
+ return { ok: true }
126
+ }],
127
+
128
+ ['GET', /^\/v1\/orgs\/([^/]+)\/roles$/, async (req, body, [slug]) => {
129
+ const a = await orgFor(req, slug)
130
+ // People who hand out roles (changing members, inviting) need the list to pick from.
131
+ if (!a.can('members', 'u') && !a.can('invites', 'c')) a.need('roles', 'r')
132
+ return { roles: sortRoles(await store.listRoles(a.org.id)).map(roleView) }
133
+ }],
134
+
135
+ ['POST', /^\/v1\/orgs\/([^/]+)\/roles$/, async (req, body, [slug]) => {
136
+ const a = await orgFor(req, slug)
137
+ a.need('roles', 'c')
138
+ const grants = normalizeGrants(body.grants)
139
+ if (!a.covers(grants)) throw new HttpError(403, 'a role can only have permissions you have')
140
+ const name = cleanName(body.name, 40, 'give the role a name')
141
+ checkNotReserved(name)
142
+ return { role: roleView(await store.createRole({ orgId: a.org.id, name, grants })) }
143
+ }],
144
+
145
+ ['PUT', /^\/v1\/orgs\/([^/]+)\/roles\/([^/]+)$/, async (req, body, [slug, id]) => {
146
+ const a = await orgFor(req, slug)
147
+ a.need('roles', 'u')
148
+ const role = await roleIn(a, id)
149
+ if (role.builtin === 'owner') throw new HttpError(403, "the Owner role can't be edited")
150
+ if (!a.covers(role.grants)) throw new HttpError(403, "this role has permissions you don't have")
151
+ const patch = {}
152
+ if (body.name !== undefined) {
153
+ const name = cleanName(body.name, 40, 'give the role a name')
154
+ if (role.builtin && name !== role.name) throw new HttpError(400, 'built-in roles keep their names')
155
+ if (!role.builtin) checkNotReserved(name)
156
+ patch.name = name
157
+ }
158
+ if (body.grants !== undefined) {
159
+ patch.grants = normalizeGrants(body.grants)
160
+ if (!a.covers(patch.grants)) throw new HttpError(403, 'a role can only have permissions you have')
161
+ }
162
+ if (!Object.keys(patch).length) return { role: roleView(role) }
163
+ return { role: roleView(await store.updateRole(role.id, patch)) }
164
+ }],
165
+
166
+ ['DELETE', /^\/v1\/orgs\/([^/]+)\/roles\/([^/]+)$/, async (req, body, [slug, id]) => {
167
+ const a = await orgFor(req, slug)
168
+ a.need('roles', 'd')
169
+ const role = await roleIn(a, id)
170
+ if (role.builtin) throw new HttpError(400, "built-in roles can't be deleted")
171
+ if (!a.covers(role.grants)) throw new HttpError(403, "this role has permissions you don't have")
172
+ if (await store.roleInUse(role.id)) throw new HttpError(409, 'this role is still assigned; move its people to another role first')
173
+ await store.deleteRole(role.id)
174
+ return { ok: true }
175
+ }]
176
+ ]
177
+ }
@@ -0,0 +1,90 @@
1
+ // The relay on many machines (src/api/relay-cluster.js): where each room lives, each machine's
2
+ // minute, hosted agents' sessions across machines, and whether a session has someone in it.
3
+ // Only the relay calls these, with RELAY_API_SECRET.
4
+ import crypto from 'node:crypto'
5
+ import { HttpError } from '../http.js'
6
+
7
+ const ROOM = /^[A-Za-z0-9_-]{1,64}$/
8
+ const MACHINE = /^[A-Za-z0-9_-]{1,64}$/
9
+ const ACCOUNT = /^(person|agent):[A-Za-z0-9_-]{1,64}$/
10
+ const digest = (s) => crypto.createHash('sha256').update(String(s)).digest()
11
+ // A machine's report lists every loaded room; hello lists every room on its disk.
12
+ export const REPORT_BODY = 1024 * 1024
13
+ export const HELLO_BODY = 2 * 1024 * 1024
14
+ const MAX_ROOMS = 20000
15
+ const MAX_WEBHOOK = 4096
16
+
17
+ export function relayClusterRoutes ({ store, bearer, relaySecret, cluster }) {
18
+ const fromRelay = (req) => {
19
+ if (!relaySecret) throw new HttpError(503, 'the relay is not set up on this server')
20
+ if (!crypto.timingSafeEqual(digest(bearer(req)), digest(relaySecret))) throw new HttpError(401, 'only the relay may ask for this')
21
+ }
22
+ const machineOf = (b) => {
23
+ if (!MACHINE.test(String(b.machine || ''))) throw new HttpError(400, 'give the machine id')
24
+ return String(b.machine)
25
+ }
26
+ const roomOf = (r) => {
27
+ if (!ROOM.test(String(r || ''))) throw new HttpError(400, 'give the room')
28
+ return String(r)
29
+ }
30
+ const accountOf = (a) => {
31
+ if (!ACCOUNT.test(String(a || ''))) throw new HttpError(400, 'give the account')
32
+ return String(a)
33
+ }
34
+ const roomList = (v) => {
35
+ if (v === undefined) return []
36
+ if (!Array.isArray(v) || v.length > MAX_ROOMS) throw new HttpError(400, `rooms must be a list of at most ${MAX_ROOMS}`)
37
+ return v
38
+ }
39
+
40
+ return [
41
+ // A relay machine started: the rooms on its disk are its own, unless placed elsewhere (it moves those aside).
42
+ ['POST', /^\/v1\/relay\/machines\/hello$/, async (req, b) => {
43
+ fromRelay(req)
44
+ return cluster.hello({ machine: machineOf(b), region: String(b.region || ''), capacity: Number(b.capacity), rooms: roomList(b.rooms).filter((r) => ROOM.test(String(r))) })
45
+ }, { maxBody: HELLO_BODY }],
46
+ // A machine's minute: its load, and each loaded room's traffic and size.
47
+ ['POST', /^\/v1\/relay\/machines\/report$/, async (req, b) => {
48
+ fromRelay(req)
49
+ const load = b.load && typeof b.load === 'object' && !Array.isArray(b.load) ? Object.fromEntries(Object.entries(b.load).filter(([, v]) => typeof v === 'number' && Number.isFinite(v)).slice(0, 20)) : {}
50
+ return cluster.report({ machine: machineOf(b), load, rooms: roomList(b.rooms) })
51
+ }, { maxBody: REPORT_BODY }],
52
+ // Where a room lives (placing it now if nobody has).
53
+ ['POST', /^\/v1\/relay\/placement$/, async (req, b) => {
54
+ fromRelay(req)
55
+ return cluster.place({ room: roomOf(b.room), machine: machineOf(b), have: b.have === true, create: b.create !== false })
56
+ }],
57
+ // A machine deleted a room for good (ended and swept, or idle past its days).
58
+ ['POST', /^\/v1\/relay\/placement\/forget$/, async (req, b) => {
59
+ fromRelay(req)
60
+ return cluster.forget({ room: roomOf(b.room), machine: machineOf(b) })
61
+ }],
62
+ // A hosted agent's sessions (on any machine) and its one webhook.
63
+ ['GET', /^\/v1\/relay\/hosted\/([^/]+)$/, async (req, b, [account]) => {
64
+ fromRelay(req)
65
+ const a = accountOf(account)
66
+ return { rooms: await store.hostedRoomsOf(a), webhook: await store.hostedWebhookOf(a) }
67
+ }],
68
+ ['POST', /^\/v1\/relay\/hosted\/([^/]+)\/rooms$/, async (req, b, [account]) => {
69
+ fromRelay(req)
70
+ const a = accountOf(account)
71
+ if (b.add !== undefined) await store.addHostedRoom(a, roomOf(b.add))
72
+ if (b.remove !== undefined) await store.removeHostedRoom(a, roomOf(b.remove))
73
+ return { rooms: await store.hostedRoomsOf(a) }
74
+ }],
75
+ ['PUT', /^\/v1\/relay\/hosted\/([^/]+)\/webhook$/, async (req, b, [account]) => {
76
+ fromRelay(req)
77
+ const a = accountOf(account)
78
+ const w = b.webhook
79
+ if (w !== null && (!w || typeof w !== 'object' || Array.isArray(w) || JSON.stringify(w).length > MAX_WEBHOOK || !/^https?:\/\//.test(String(w.url || '')))) throw new HttpError(400, 'give the webhook, or null')
80
+ // Removed is kept as { off: true }, so a machine with an older copy drops it instead of sharing it again.
81
+ await store.putHostedWebhook(a, w || { off: true })
82
+ return { ok: true }
83
+ }],
84
+ // Whether `account` owns session `room` or has been in it (a branch's live session elsewhere, plan §8).
85
+ ['POST', /^\/v1\/relay\/same-owner$/, async (req, b) => {
86
+ fromRelay(req)
87
+ return { same: await store.roomHasAccount(roomOf(b.room), accountOf(b.account)) }
88
+ }]
89
+ ]
90
+ }