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,196 @@
1
+ // Inviting people to a workspace, and the invites waiting for you.
2
+ //
3
+ // A workspace admin invites a person they've worked with (or someone in the workspace's org)
4
+ // or anyone by email; nothing changes until that person accepts, in the app's home or on the
5
+ // website's dashboard. Agents have no inbox: they're still added straight away (members PUT).
6
+ //
7
+ // GET /v1/me/invites lists every invite waiting for the caller: workspace invites, and
8
+ // session invites with their join link (kept only for the account or address it was sent to).
9
+ // A person's confirmed sign-in address counts as theirs, so an invite sent to an email before
10
+ // they had an account is waiting when they sign up.
11
+ import { HttpError, needId, stripInvisible } from '../http.js'
12
+ import { emailDomain } from '../domains.js'
13
+ import { workspaceInviteEmail } from '../invite-email.js'
14
+ import { cleanAccess } from '../workspace-access.js'
15
+ import { workspaceReach } from '../workspace-reach.js'
16
+ import { typeOfGrant } from '../access.js'
17
+ import { collaboratorsOf } from './sessions.js'
18
+
19
+ export const WORKSPACE_INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000
20
+ const STRICT_EMAIL = /^[^\s@,;<>"()\\]+@[^\s@,;<>"()\\]+$/
21
+ const PERSON = /^person:[A-Za-z0-9_-]{1,64}$/
22
+ const TAKEN = 'They already have an open invite. Cancel it first.'
23
+
24
+ export function workspaceInviteRoutes (ctx) {
25
+ const { store, person, now, site, mailer, log = () => {}, limitSend = () => {}, workspaces = false } = ctx
26
+ const { reach } = workspaceReach(ctx)
27
+ const gated = (fn) => (...args) => {
28
+ if (!workspaces) throw new HttpError(404, 'not found')
29
+ return fn(...args)
30
+ }
31
+ const statusOf = (i) => (i.acceptedAt ? 'accepted' : i.declinedAt ? 'declined' : i.cancelledAt ? 'cancelled' : i.expiresAt <= now() ? 'expired' : 'waiting')
32
+ // An account invite shows the name the inviter saw, never the person's email.
33
+ const view = (i) => ({ id: i.id, ...(i.email ? { email: i.email } : { account: i.account, name: i.accountName }), access: i.access, status: statusOf(i), createdAt: i.createdAt, expiresAt: i.expiresAt })
34
+ const nameOf = async (account) => {
35
+ const [kind, id] = String(account || '').split(':')
36
+ const raw = kind === 'agent' ? (await store.agentById(id).catch(() => null))?.name : kind === 'person' ? (await store.profile(id))?.name : ''
37
+ return stripInvisible(raw || '').join('').trim()
38
+ }
39
+
40
+ function cleanEmail (raw) {
41
+ const email = String(raw || '').trim().toLowerCase()
42
+ if (!email || email.length > 254 || !STRICT_EMAIL.test(email) || !emailDomain(email)) throw new HttpError(400, "That email doesn't look right.")
43
+ return email
44
+ }
45
+
46
+ /** Who the caller is to an invite: their account, and their confirmed sign-in address (if any). */
47
+ async function mine (req) {
48
+ const { userId } = await person(req)
49
+ const mail = await store.userEmail(userId)
50
+ const email = mail?.confirmed && mail.email ? String(mail.email).toLowerCase() : null
51
+ return { userId, account: `person:${userId}`, email }
52
+ }
53
+
54
+ async function send (to, { inviter, ws, access }) {
55
+ const msg = workspaceInviteEmail({ inviterName: (await nameOf(inviter)) || undefined, workspaceName: ws.name, access, site })
56
+ try {
57
+ await mailer.send({ to, ...msg })
58
+ } catch (err) {
59
+ log(`workspace invite email failed: ${err?.stack || err?.message || err}`)
60
+ throw new HttpError(502, "The invite was saved, but the email didn't send. It still shows in their Quilt; cancel and invite again to resend it.")
61
+ }
62
+ }
63
+
64
+ async function create (fields) {
65
+ try {
66
+ return await store.createWorkspaceInvite({ ...fields, at: now() })
67
+ } catch (err) {
68
+ if (err.code === '23505') throw new HttpError(409, TAKEN)
69
+ throw err
70
+ }
71
+ }
72
+
73
+ /** Whether `account` is already in the workspace: its owner or a member. */
74
+ async function alreadyIn (ws, account) {
75
+ return (ws.ownerUserId && account === `person:${ws.ownerUserId}`) || !!(await store.workspaceMember(ws.id, account))
76
+ }
77
+
78
+ /** An invite waiting for the caller; 404 for anything else (theirs or not, so ids can't be probed). */
79
+ async function waitingFor (me, id) {
80
+ const i = await store.workspaceInviteById(needId(id, 'invite'))
81
+ if (!i || !((i.account && i.account === me.account) || (i.email && i.email === me.email))) throw new HttpError(404, 'no such invite')
82
+ if (statusOf(i) !== 'waiting') throw new HttpError(410, statusOf(i) === 'expired' ? 'This invite has expired. Ask for a new one.' : `This invite was already ${statusOf(i)}.`)
83
+ return i
84
+ }
85
+
86
+ const routes = [
87
+ ['GET', /^\/v1\/workspaces\/([^/]+)\/invites$/, gated(async (req, body, [id]) => {
88
+ const r = await reach(req, id)
89
+ r.needAdmin()
90
+ return { invites: (await store.listWorkspaceInvites(r.ws.id)).map(view) }
91
+ })],
92
+
93
+ // { to: { email } | { account: 'person:<id>' }, access }. A person, by account, is someone
94
+ // the inviter has worked with or someone in the workspace's org.
95
+ ['POST', /^\/v1\/workspaces\/([^/]+)\/invites$/, gated(async (req, body, [id]) => {
96
+ const r = await reach(req, id)
97
+ r.needAdmin()
98
+ if (!r.me.userId) throw new HttpError(403, 'Agents add agents; people invite people.')
99
+ limitSend(r.me.userId)
100
+ const access = cleanAccess(body.access || 'edit')
101
+ const to = body.to && typeof body.to === 'object' ? body.to : {}
102
+ const base = { workspaceId: r.ws.id, access, invitedBy: r.me.account, expiresAt: now() + WORKSPACE_INVITE_TTL_MS }
103
+
104
+ if (to.email !== undefined) {
105
+ const email = cleanEmail(to.email)
106
+ if (await store.openWorkspaceInvite(r.ws.id, { email }, now())) throw new HttpError(409, 'That address already has an open invite. Cancel it first.')
107
+ const invite = await create({ ...base, email })
108
+ await send(email, { inviter: r.me.account, ws: r.ws, access })
109
+ return { invite: view(invite) }
110
+ }
111
+
112
+ const account = String(to.account || '')
113
+ if (!PERSON.test(account)) throw new HttpError(400, 'Invite a person by email, or someone you have worked with. Agents are added, not invited.')
114
+ if (account === r.me.account) throw new HttpError(400, "That's you.")
115
+ const userId = account.slice('person:'.length)
116
+ const known = (await collaboratorsOf(store, r.me.account, now())).find((c) => c.account === account)
117
+ const inOrg = r.ws.orgId && await store.memberOf(r.ws.orgId, userId)
118
+ if (!known && !inOrg) throw new HttpError(404, "You can invite people you've worked with, or anyone by email.")
119
+ if (await alreadyIn(r.ws, account)) throw new HttpError(409, 'They are already in this workspace.')
120
+ if (await store.openWorkspaceInvite(r.ws.id, { account }, now())) throw new HttpError(409, TAKEN)
121
+ const name = known?.name || await nameOf(account)
122
+ const invite = await create({ ...base, account, accountName: String(name || '').slice(0, 64) })
123
+ // They hear about it at their sign-in email too (looked up here, never sent back).
124
+ const mail = await store.userEmail(userId)
125
+ if (mail?.confirmed && mail.email) await send(mail.email, { inviter: r.me.account, ws: r.ws, access })
126
+ return { invite: view(invite) }
127
+ })],
128
+
129
+ ['DELETE', /^\/v1\/workspaces\/([^/]+)\/invites\/([^/]+)$/, gated(async (req, body, [id, inviteId]) => {
130
+ const r = await reach(req, id)
131
+ r.needAdmin()
132
+ const i = await store.workspaceInviteById(needId(inviteId, 'invite'))
133
+ if (!i || i.workspaceId !== r.ws.id) throw new HttpError(404, 'no such invite')
134
+ if (!await store.answerWorkspaceInvite(i.id, 'cancelled')) throw new HttpError(409, 'That invite was already answered or cancelled.')
135
+ return { ok: true }
136
+ })],
137
+
138
+ // Every invite waiting for you, newest first: workspaces (with workspaces on) and sessions.
139
+ ['GET', /^\/v1\/me\/invites$/, async (req) => {
140
+ const me = await mine(req)
141
+ const t = now()
142
+ const out = []
143
+ if (workspaces) {
144
+ for (const i of await store.workspaceInvitesFor(me, t)) {
145
+ const ws = await store.workspaceById(i.workspaceId)
146
+ if (!ws) continue
147
+ const org = ws.orgId ? await store.orgById(ws.orgId) : null
148
+ out.push({ kind: 'workspace', id: i.id, workspace: { id: ws.id, name: ws.name, color: ws.color, org: org ? org.name : null }, access: i.access, from: { account: i.invitedBy, name: await nameOf(i.invitedBy) }, createdAt: i.createdAt, expiresAt: i.expiresAt })
149
+ }
150
+ }
151
+ for (const i of await store.sessionInvitesFor(me, t)) {
152
+ // An invite made before links were kept can't be joined from here: it's in their email.
153
+ const type = await typeOfGrant(store, { typeId: i.typeId })
154
+ out.push({ kind: 'session', id: i.id, session: { room: i.room, name: i.sessionName || 'Untitled session' }, link: i.link || null, access: type.name, from: { account: i.invitedBy, name: await nameOf(i.invitedBy) }, createdAt: i.createdAt, expiresAt: i.expiresAt })
155
+ }
156
+ return { invites: out.sort((a, b) => b.createdAt - a.createdAt) }
157
+ }],
158
+
159
+ // Accepting a workspace invite makes you a member. (A session invite is accepted by
160
+ // joining with its link: the grant it made lets you straight in.)
161
+ ['POST', /^\/v1\/me\/invites\/([^/]+)\/accept$/, gated(async (req, body, [id]) => {
162
+ const me = await mine(req)
163
+ const i = await waitingFor(me, id)
164
+ const ws = await store.workspaceById(i.workspaceId)
165
+ if (!ws) throw new HttpError(404, 'That workspace was deleted.')
166
+ if (ws.orgId && !(await store.memberOf(ws.orgId, me.userId))) {
167
+ const org = await store.orgById(ws.orgId)
168
+ throw new HttpError(403, `This workspace belongs to ${org?.name || 'an org'}. Ask them to add you to the org, then accept again.`)
169
+ }
170
+ if (!await store.answerWorkspaceInvite(i.id, 'accepted', me.account)) throw new HttpError(410, 'This invite was already answered.')
171
+ // Never lower the access someone already has.
172
+ const had = await store.workspaceMember(ws.id, me.account)
173
+ const owner = ws.ownerUserId === me.userId
174
+ if (!owner && !(had && had.access === 'edit')) await store.putWorkspaceMember({ workspaceId: ws.id, account: me.account, access: i.access, addedBy: i.invitedBy })
175
+ return { workspace: { id: ws.id, name: ws.name } }
176
+ })],
177
+
178
+ // Declining: a workspace invite is answered; a session invite is cancelled and the access
179
+ // it gave is taken back (unless another open invite still needs it).
180
+ ['POST', /^\/v1\/me\/invites\/([^/]+)\/decline$/, async (req, body, [id]) => {
181
+ const me = await mine(req)
182
+ const inviteId = needId(id, 'invite')
183
+ const s = await store.sessionInviteForMe(inviteId, me)
184
+ if (s) {
185
+ if (!await store.cancelSessionInvite(s.id)) throw new HttpError(410, 'This invite was already used or cancelled.')
186
+ await store.deleteUnusedGrant(s.room, s.email ? `email:${s.email}` : s.account, now())
187
+ return { ok: true }
188
+ }
189
+ if (!workspaces) throw new HttpError(404, 'no such invite')
190
+ const i = await waitingFor(me, inviteId)
191
+ if (!await store.answerWorkspaceInvite(i.id, 'declined')) throw new HttpError(410, 'This invite was already answered.')
192
+ return { ok: true }
193
+ }]
194
+ ]
195
+ return routes
196
+ }
@@ -0,0 +1,225 @@
1
+ // Workspaces: a container owned by a person or an org, holding sessions and members.
2
+ // Behind the QUILT_WORKSPACES flag (startApi({ workspaces })): off, every route answers 404.
3
+ import { HttpError, cleanName } from '../http.js'
4
+ import { orgAccess } from '../org-access.js'
5
+ import { workspaceAccess, cleanColor, cleanDescription, cleanAccess, autoColor } from '../workspace-access.js'
6
+ import { workspaceReach, canSeeFiles } from '../workspace-reach.js'
7
+ import { listedFiles, usageView, removeObjects } from './workspace-files.js'
8
+ import { workspaceAgents, cleanMemberSessions, placementCandidates, FOREIGN_JOINS } from './workspace-agents.js'
9
+ import { sameOwnerAgent } from '../agent-placement.js'
10
+ import { typeOfGrant } from '../access.js'
11
+ import { effectiveAccess } from '../../session-access.js'
12
+
13
+ const ROOM = /^[A-Za-z0-9_-]{1,64}$/
14
+ const ACCOUNT = /^(person|agent):[A-Za-z0-9_-]{1,64}$/
15
+ const OPEN_MS = 10 * 60 * 1000
16
+
17
+ export function workspaceRoutes (ctx) {
18
+ const { store, now, files: fileStore, workspaces = false, log = () => {} } = ctx
19
+ const { caller, grantsIn, reach } = workspaceReach(ctx)
20
+ /** A handler that answers 404 while the flag is off (a route's own 404, not a missing route). */
21
+ const gated = (fn) => (...args) => {
22
+ if (!workspaces) throw new HttpError(404, 'not found')
23
+ return fn(...args)
24
+ }
25
+
26
+ const nameOf = async (account) => {
27
+ const [kind, id] = account.split(':')
28
+ if (kind === 'agent') return (await store.agentById(id))?.name
29
+ return (await store.profile(id))?.name
30
+ }
31
+ const kindOf = (account) => account.split(':')[0]
32
+ const isOpen = (s, t) => t - s.lastActiveAt < OPEN_MS
33
+ const sessionView = (s, t) => ({ room: s.room, name: s.name, ownerAccount: s.ownerAccount, lastActiveAt: s.lastActiveAt, open: isOpen(s, t) })
34
+
35
+ /**
36
+ * Moving a session into a workspace brings its people and agents along: everyone with a
37
+ * grant there (edit or view, as the grant gives files) and everyone who has been in it
38
+ * (edit, as an invite link lets them), except its owner, agents kept out of it, people
39
+ * the owner removed last time, and, in an org's workspace, anyone outside the org. Nobody
40
+ * loses access they already had here. Answers who was added.
41
+ */
42
+ async function bringPeople (ws, room, by) {
43
+ const want = new Map()
44
+ for (const g of await store.listGrants(room)) {
45
+ if (!ACCOUNT.test(g.account)) continue // an email's grant waits for that person to sign in
46
+ want.set(g.account, effectiveAccess(await typeOfGrant(store, g), g.tighten).files === 'edit' ? 'edit' : 'view')
47
+ }
48
+ const last = new Map()
49
+ for (const v of await store.visitsInRooms([room])) if (ACCOUNT.test(v.account)) last.set(v.account, v)
50
+ for (const [account, v] of last) if (!want.has(account) && v.endReason !== 'removed') want.set(account, 'edit')
51
+ const keptOut = new Set((await store.listSessionAgentExclusions(room).catch(() => [])).map((e) => `agent:${e.agentId}`))
52
+ const added = []
53
+ for (const [account, access] of want) {
54
+ if (account === by || keptOut.has(account) || (ws.ownerUserId && account === `person:${ws.ownerUserId}`)) continue
55
+ const [kind, id] = account.split(':')
56
+ if (kind === 'agent' ? !(await store.agentById(id).catch(() => null)) : !(await store.profile(id))) continue
57
+ if (ws.orgId && !(kind === 'agent' ? await store.memberByAgent(ws.orgId, id) : await store.memberOf(ws.orgId, id))) continue
58
+ const had = await store.workspaceMember(ws.id, account)
59
+ if (had && (had.access === 'edit' || access === 'view')) continue
60
+ await store.putWorkspaceMember({ workspaceId: ws.id, account, access, addedBy: by })
61
+ added.push({ account, name: (await nameOf(account)) || last.get(account)?.accountName || '', kind, access })
62
+ }
63
+ return added
64
+ }
65
+
66
+ async function spaceOf (ws) {
67
+ if (!ws.orgId) return { kind: 'personal' }
68
+ const org = await store.orgById(ws.orgId)
69
+ return { kind: 'org', slug: org?.slug || '', name: org?.name || '' }
70
+ }
71
+
72
+ async function listView (ws, access, t) {
73
+ const sessions = await store.listWorkspaceSessions(ws.id)
74
+ const members = await store.listWorkspaceMembers(ws.id)
75
+ return {
76
+ id: ws.id,
77
+ name: ws.name,
78
+ description: ws.description,
79
+ color: ws.color,
80
+ createdAt: ws.createdAt,
81
+ archivedAt: ws.archivedAt,
82
+ space: await spaceOf(ws),
83
+ access: access.access,
84
+ admin: access.admin,
85
+ via: access.via,
86
+ // files: the row's count, kept by every upload, delete and sweep (setWorkspaceUsage).
87
+ counts: { sessions: sessions.length, members: members.length, open: sessions.filter((s) => isOpen(s, t)).length, files: ws.fileCount ?? 0 }
88
+ }
89
+ }
90
+
91
+ const routes = [
92
+ // Every workspace the caller can reach: their own, their orgs' (per their role), and the ones they're a member of.
93
+ ['GET', /^\/v1\/me\/workspaces$/, async (req) => {
94
+ const me = await caller(req)
95
+ const t = now()
96
+ const seen = new Map()
97
+ const add = async (ws) => {
98
+ if (seen.has(ws.id)) return
99
+ const access = await workspaceAccess(store, ws, me.account, { orgGrants: await grantsIn(ws, me) })
100
+ if (access) seen.set(ws.id, await listView(ws, access, t))
101
+ }
102
+ if (me.userId) {
103
+ for (const ws of await store.listWorkspacesOwnedBy(me.userId)) await add(ws)
104
+ for (const org of await store.orgsForUser(me.userId)) for (const ws of await store.listWorkspacesOfOrg(org.id)) await add(ws)
105
+ }
106
+ for (const ws of await store.listWorkspacesForMember(me.account)) await add(ws)
107
+ // An agent also finds the workspaces its placement reaches (its owner's or its org's;
108
+ // workspaceAccess answers null for the rest).
109
+ if (me.account.startsWith('agent:')) for (const ws of await placementCandidates(store, me.agent || await store.agentById(me.account.slice(6)))) await add(ws)
110
+ return { workspaces: [...seen.values()].sort((a, b) => a.name.localeCompare(b.name)) }
111
+ }],
112
+
113
+ ['POST', /^\/v1\/workspaces$/, async (req, body) => {
114
+ const me = await caller(req)
115
+ if (!me.userId) throw new HttpError(403, 'agents do not make workspaces')
116
+ const name = cleanName(body.name, 80, 'give the workspace a name')
117
+ // Colour and description are set later, in settings; a new workspace gets a colour from its name.
118
+ const fields = { name, description: cleanDescription(body.description), color: cleanColor(body.color) || autoColor(name), createdBy: me.account }
119
+ if (body.org) {
120
+ const a = await orgAccess(store, me.userId, body.org)
121
+ a.need('workspaces', 'c')
122
+ return { workspace: await store.createWorkspace({ ...fields, orgId: a.org.id }) }
123
+ }
124
+ return { workspace: await store.createWorkspace({ ...fields, ownerUserId: me.userId }) }
125
+ }],
126
+
127
+ ['GET', /^\/v1\/workspaces\/([^/]+)$/, async (req, body, [id]) => {
128
+ const { ws, access, canDelete } = await reach(req, id)
129
+ const t = now()
130
+ const members = await Promise.all((await store.listWorkspaceMembers(ws.id)).map(async (m) => ({ account: m.account, name: (await nameOf(m.account)) || '', kind: kindOf(m.account), access: m.access, addedAt: m.addedAt })))
131
+ const ownerAccount = ws.ownerUserId ? `person:${ws.ownerUserId}` : null
132
+ const owner = ownerAccount ? { account: ownerAccount, name: (await nameOf(ownerAccount)) || '' } : { account: null, name: (await store.orgById(ws.orgId))?.name || '' }
133
+ const sessions = (await store.listWorkspaceSessions(ws.id)).map((s) => sessionView(s, t))
134
+ const files = canSeeFiles(access) ? await listedFiles(store, await store.listWorkspaceFiles(ws.id)) : []
135
+ const agents = await workspaceAgents(store, ws, { admin: access.admin })
136
+ return { workspace: ws, access, canDelete, owner, members, agents, sessions, files, usage: await usageView(store, ws, ctx) }
137
+ }],
138
+
139
+ ['PATCH', /^\/v1\/workspaces\/([^/]+)$/, async (req, body, [id]) => {
140
+ const r = await reach(req, id)
141
+ r.needAdmin()
142
+ const patch = {}
143
+ if (body.name !== undefined) patch.name = cleanName(body.name, 80, 'give the workspace a name')
144
+ if (body.description !== undefined) patch.description = cleanDescription(body.description)
145
+ if (body.color !== undefined) patch.color = cleanColor(body.color)
146
+ if (body.archived !== undefined) patch.archivedAt = body.archived ? now() : null
147
+ return { workspace: await store.updateWorkspace(r.ws.id, patch) }
148
+ }],
149
+
150
+ ['DELETE', /^\/v1\/workspaces\/([^/]+)$/, async (req, body, [id]) => {
151
+ const r = await reach(req, id)
152
+ if (r.ws.orgId) {
153
+ if (!r.me.userId) throw new HttpError(403, 'agents do not delete workspaces')
154
+ const a = await orgAccess(store, r.me.userId, (await store.orgById(r.ws.orgId)).slug)
155
+ a.need('workspaces', 'd')
156
+ } else if (r.access.via !== 'owner') throw new HttpError(403, 'only the owner can delete a workspace')
157
+ // Its files' bytes go first (every version, and files deleted but not yet swept): once
158
+ // the rows are gone nothing would ever name these objects again.
159
+ const keys = []
160
+ for (const f of await store.listWorkspaceFiles(r.ws.id, { includeDeleted: true })) {
161
+ if (f.kind !== 'file') continue
162
+ if (f.objectKey) keys.push(f.objectKey)
163
+ for (const v of await store.listWorkspaceFileVersions(f.id)) if (v.objectKey) keys.push(v.objectKey)
164
+ }
165
+ if (fileStore) await removeObjects(fileStore, keys, log)
166
+ await store.deleteWorkspace(r.ws.id)
167
+ return { ok: true }
168
+ }],
169
+
170
+ ['PUT', /^\/v1\/workspaces\/([^/]+)\/members\/([^/]+)$/, async (req, body, [id, account]) => {
171
+ const r = await reach(req, id)
172
+ r.needAdmin()
173
+ if (!ACCOUNT.test(account)) throw new HttpError(400, 'that is not an account')
174
+ const access = cleanAccess(body.access)
175
+ const [kind, who] = account.split(':')
176
+ // Whether an agent also joins every session here as it starts; kept when not sent.
177
+ const sessions = cleanMemberSessions(body.sessions, kind)
178
+ if (kind === 'agent' ? !(await store.agentById(who)) : !(await store.profile(who))) throw new HttpError(404, 'no such account')
179
+ if (r.ws.orgId && kind === 'person' && !(await store.memberOf(r.ws.orgId, who))) throw new HttpError(404, 'that person is not in the org')
180
+ if (r.ws.orgId && kind === 'agent' && !(await store.memberByAgent(r.ws.orgId, who))) throw new HttpError(404, 'that agent is not in the org')
181
+ // Someone else's agent can be added for access, but only its owner makes it join every session.
182
+ if (sessions === 'all' && !(await sameOwnerAgent(store, r.ws, who))) throw new HttpError(400, FOREIGN_JOINS)
183
+ const member = await store.putWorkspaceMember({ workspaceId: r.ws.id, account, access, addedBy: r.me.account, sessions })
184
+ // Answered as GET reports it: an old 'all' on someone else's agent has no effect.
185
+ if (kind === 'agent' && member.sessions === 'all' && !(await sameOwnerAgent(store, r.ws, who))) return { member: { ...member, sessions: 'invited' } }
186
+ return { member }
187
+ }],
188
+
189
+ ['DELETE', /^\/v1\/workspaces\/([^/]+)\/members\/([^/]+)$/, async (req, body, [id, account]) => {
190
+ const r = await reach(req, id)
191
+ r.needAdmin()
192
+ if (!(await store.removeWorkspaceMember(r.ws.id, account))) throw new HttpError(404, 'not a member')
193
+ return { ok: true }
194
+ }],
195
+
196
+ // Puts a session in the workspace. The app calls this as soon as it has made the room,
197
+ // usually before the relay's first presence report. The API records who linked it, never
198
+ // an owner: only the relay names that, and members get in only once the two agree (access.js).
199
+ ['POST', /^\/v1\/workspaces\/([^/]+)\/sessions$/, async (req, body, [id]) => {
200
+ const r = await reach(req, id)
201
+ if (r.access.access !== 'edit') throw new HttpError(403, 'you can only view this workspace')
202
+ const room = String(body.room || '')
203
+ if (!ROOM.test(room)) throw new HttpError(400, 'room must be a session name')
204
+ const existing = await store.sessionByRoom(room)
205
+ if (existing && existing.ownerAccount && existing.ownerAccount !== r.me.account) throw new HttpError(403, 'only the session owner can move it')
206
+ const session = await store.setSessionWorkspace(room, r.ws.id, { linkedBy: r.me.account, at: now() })
207
+ // Its people come along when the owner manages the workspace; an editor's move leaves
208
+ // the workspace's people as they are, and says so (`peopleNeedAdmin`).
209
+ const owner = existing && existing.ownerAccount === r.me.account
210
+ if (!owner) return { session, added: [] }
211
+ if (!r.access.admin) return { session, added: [], peopleNeedAdmin: true }
212
+ return { session, added: await bringPeople(r.ws, room, r.me.account) }
213
+ }],
214
+
215
+ ['DELETE', /^\/v1\/workspaces\/([^/]+)\/sessions\/([^/]+)$/, async (req, body, [id, room]) => {
216
+ const r = await reach(req, id)
217
+ const s = await store.sessionByRoom(room)
218
+ if (!s || s.workspaceId !== r.ws.id) throw new HttpError(404, 'that session is not in this workspace')
219
+ if (s.ownerAccount !== r.me.account && !r.access.admin) throw new HttpError(403, 'only the session owner or a workspace admin can do that')
220
+ await store.setSessionWorkspace(room, null, { linkedBy: null, at: now() })
221
+ return { ok: true }
222
+ }]
223
+ ]
224
+ return routes.map(([method, pattern, fn, ...options]) => [method, pattern, gated(fn), ...options])
225
+ }