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,380 @@
1
+ // Connecting GitHub (github-app.js): a person starts from the app or the website, GitHub asks them
2
+ // which repositories to install Quilt on and who they are, and comes back here. The relay then asks
3
+ // for a repository's credentials for a session's owner when an agent commits.
4
+ import crypto from 'node:crypto'
5
+ import { HttpError, Raw } from '../http.js'
6
+ import { githubApp, signState, readStateFull, WRITE_ROLES, READ_ROLES, GitHubAppError } from '../github-app.js'
7
+ import { orgAccess, orgGrantsFor } from '../org-access.js'
8
+ import { workspaceAccess } from '../workspace-access.js'
9
+ export { ensureWebhookSecret, redeliverFailed } from '../github-app.js'
10
+
11
+ const PART = /^[A-Za-z0-9_.-]{1,100}$/
12
+ /** A lease lapses this long after its last heartbeat: two missed heartbeats (plan §8). */
13
+ export const LEASE_TTL_MS = 2 * 60 * 1000
14
+ const ACCOUNT = /^person:([A-Za-z0-9_-]{1,64})$/
15
+ const ACTOR = /^(person|agent):([A-Za-z0-9_-]{1,64})$/
16
+ const ROOM = /^[A-Za-z0-9_-]{1,64}$/
17
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
18
+ const MAX_REPOS = 200
19
+ /** owner/name from what a person typed ("github.com/acme/widgets.git", "acme/widgets"), or null. */
20
+ const cleanRepo = (v) => { const m = /^(?:https?:\/\/)?(?:github\.com\/)?([A-Za-z0-9_.-]{1,100})\/([A-Za-z0-9_.-]{1,100}?)(?:\.git)?\/?$/.exec(String(v || '').trim()); return m ? `${m[1]}/${m[2]}` : null }
21
+ const digest = (s) => crypto.createHash('sha256').update(String(s)).digest()
22
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
23
+
24
+ /** The page GitHub sends people back to: plain, so it works from any browser. */
25
+ const page = (title, text) => new Raw(200, `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title>
26
+ <style>body{font:16px/1.5 system-ui,sans-serif;background:#fdf6f0;color:#2b2420;display:grid;place-items:center;min-height:100vh;margin:0;padding:16px}main{max-width:420px;text-align:center}h1{font-size:22px}</style>
27
+ <main><h1>${esc(title)}</h1><p>${esc(text)}</p></main>`, 'text/html; charset=utf-8')
28
+
29
+ export function githubRoutes ({ store, person, bearer, relaySecret, github: cfg, githubFetch, relayHttp = '', relayFetch = globalThis.fetch, tellRelays = null, now, log, hookSecret = { value: cfg && cfg.webhookSecret }, workspaces = false }) {
30
+ const app = cfg ? githubApp(cfg, { fetch: githubFetch, now }) : null
31
+ const need = () => { if (!app) throw new HttpError(503, 'GitHub is not set up on this server') }
32
+ const fromRelay = (req) => {
33
+ if (!relaySecret) throw new HttpError(503, 'the relay is not set up on this server')
34
+ if (!crypto.timingSafeEqual(digest(bearer(req)), digest(relaySecret))) throw new HttpError(401, 'only the relay may ask for this')
35
+ }
36
+ // Credentials per account and repository, kept until a few minutes before they run out.
37
+ const cache = new Map()
38
+ // What agents may commit, for a whole workspace or org: '' (each session decides), 'off', 'branches' or 'any'.
39
+ const agentCommitsGiven = (v) => {
40
+ if (v === '' || v === null) return ''
41
+ if (['off', 'branches', 'any'].includes(v)) return v
42
+ throw new HttpError(400, 'agentCommits is "" (each session decides), off, branches or any.')
43
+ }
44
+ // A nudge for the relay: every relay machine gets it (src/api/relay-cluster.js), each for the sessions it holds.
45
+ const tell = tellRelays || (async (path, body) => {
46
+ await relayFetch(`${relayHttp}${path}`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${relaySecret}` }, body: JSON.stringify(body) })
47
+ })
48
+ const nudge = (path, body, what) => {
49
+ if (!relayHttp || !relaySecret) return Promise.resolve()
50
+ return Promise.resolve().then(() => tell(path, body)).catch((err) => log(`${what} to relay: ${err.message}`))
51
+ }
52
+ // The relay asks its sessions again now (relay /github/policy), so a change applies at once.
53
+ const policyChanged = () => { nudge('/github/policy', {}, 'agent policy') }
54
+
55
+ /** A workspace by id that `userId` may manage GitHub for (its admins), or an HttpError. */
56
+ const workspaceToManage = async (userId, id, { manage = true } = {}) => {
57
+ if (!workspaces) throw new HttpError(404, 'not found')
58
+ const ws = UUID.test(String(id || '')) ? await store.workspaceById(id) : null
59
+ const a = ws && await workspaceAccess(store, ws, `person:${userId}`, { orgGrants: ws.orgId ? await orgGrantsFor(store, ws.orgId, `person:${userId}`) : null })
60
+ if (!a) throw new HttpError(404, 'no such workspace')
61
+ if (manage && !a.admin) throw new HttpError(403, 'only the workspace\'s admins change its GitHub connection')
62
+ return { ws, admin: !!a.admin }
63
+ }
64
+ /** Where a connection attaches (plan §2): the caller ('person', the default), a workspace they manage, or an org whose settings they may change. */
65
+ const scopeFor = async (userId, b) => {
66
+ const scope = ['workspace', 'org'].includes(b.scope) ? b.scope : 'person'
67
+ if (scope === 'person') return { scope, scopeId: userId }
68
+ if (scope === 'workspace') { const { ws } = await workspaceToManage(userId, b.id); return { scope, scopeId: ws.id } }
69
+ const org = await orgAccess(store, userId, b.id)
70
+ org.need('org', 'u')
71
+ return { scope, scopeId: org.org.id }
72
+ }
73
+ /** Who connected GitHub for a scope, with their GitHub login. */
74
+ const connectionsOf = async (scope, scopeId) => {
75
+ const out = []
76
+ for (const c of await store.githubConnections({ scope, scopeId })) {
77
+ const link = await store.githubLink(c.connectedBy)
78
+ const p = await store.profile(c.connectedBy).catch(() => null)
79
+ out.push({ connectedBy: c.connectedBy, name: (p && p.name) || '', login: link ? link.login : '', at: c.createdAt })
80
+ }
81
+ return out
82
+ }
83
+ /** The person an account works for (Require GitHub): a person themselves; an agent, its owner, or (an org's agent) the session's owner. */
84
+ const personFor = async (actor, session) => {
85
+ const m = ACTOR.exec(actor || '')
86
+ if (!m) return null
87
+ if (m[1] === 'person') return m[2]
88
+ const agent = await store.agentById(m[2]).catch(() => null)
89
+ if (agent && agent.ownerUserId) return agent.ownerUserId
90
+ const owner = session && ACCOUNT.exec(session.ownerAccount || '')
91
+ return owner ? owner[1] : null
92
+ }
93
+ /**
94
+ * Whose GitHub connections may cover `owner/name` for a session (plan §2): its workspace's, then its
95
+ * org's, then its people's own, in that order; { ids } or { refused } when the workspace's access mode
96
+ * keeps the repository out. With the org's Require GitHub and an `actingFor`, only the person that
97
+ * account works for counts: an agent gets their live GitHub permissions, no more.
98
+ */
99
+ const coverFor = async ({ room, accounts, actingFor, owner, name }) => {
100
+ const ids = []
101
+ let session = null
102
+ let ws = null
103
+ if (room) session = await store.sessionByRoom(room).catch(() => null)
104
+ if (session && session.workspaceId) ws = await store.workspaceById(session.workspaceId).catch(() => null)
105
+ let require = false
106
+ if (ws) {
107
+ const g = await store.workspaceGithub(ws.id)
108
+ if (g.access === 'repos' && !g.repos.some((r) => r.toLowerCase() === `${owner}/${name}`.toLowerCase())) return { refused: { state: 'not-allowed', why: `the workspace ${ws.name} works only with the repositories it lists, and ${owner}/${name} isn't one of them` } }
109
+ for (const c of await store.githubConnections({ scope: 'workspace', scopeId: ws.id })) ids.push(c.connectedBy)
110
+ if (ws.orgId) {
111
+ require = (await store.orgGithub(ws.orgId)).requireGithub
112
+ for (const c of await store.githubConnections({ scope: 'org', scopeId: ws.orgId })) ids.push(c.connectedBy)
113
+ }
114
+ }
115
+ if (require && actingFor) {
116
+ const who = await personFor(actingFor, session)
117
+ return { ids: who ? [who] : [], require: true, actingFor }
118
+ }
119
+ return { ids: [...new Set([...ids, ...accounts])], require }
120
+ }
121
+
122
+ return [
123
+ // Where to send a person to connect GitHub: installing the app, then back to the callback.
124
+ ['POST', /^\/v1\/github\/connect$/, async (req, b) => {
125
+ need()
126
+ const { userId } = await person(req)
127
+ const where = await scopeFor(userId, b)
128
+ // For a repository: straight to its owner's (person's or organization's) install page, skipping the account picker.
129
+ const m = /^(?:github\.com\/)?([A-Za-z0-9_.-]{1,100})\/[A-Za-z0-9_.-]{1,100}$/.exec(String(b.repo || '').replace(/\.git$/, ''))
130
+ const target = m ? await app.accountId(m[1]).catch(() => null) : null
131
+ return { url: app.installUrl(signState(cfg, userId, now(), where.scope === 'person' ? {} : where), { targetId: target }) }
132
+ }],
133
+ ['GET', /^\/v1\/me\/github$/, async (req) => {
134
+ const { userId } = await person(req)
135
+ const link = await store.githubLink(userId)
136
+ return { available: !!app, connected: !!link, ...(link ? { login: link.login } : {}) }
137
+ }],
138
+ ['DELETE', /^\/v1\/me\/github$/, async (req) => {
139
+ const { userId } = await person(req)
140
+ await store.deleteGithubLink(userId)
141
+ cache.clear()
142
+ return { ok: true }
143
+ }],
144
+ // GitHub sends the person back here after the install, with who they are (code) and our state.
145
+ ['GET', /^\/v1\/github\/(callback|setup)$/, async (req) => {
146
+ need()
147
+ const q = new URL(req.url, 'http://x').searchParams
148
+ const st = readStateFull(cfg, q.get('state'), now())
149
+ const userId = st ? st.userId : null
150
+ if (!userId) return page('Start from Quilt', 'Open Quilt and click Connect GitHub in the commit panel, so Quilt knows which account to connect. Quilt is installed on GitHub either way.')
151
+ if (!q.get('code')) return page('GitHub did not sign you in', 'Try Connect GitHub again from Quilt.')
152
+ let who
153
+ try { who = await app.whoAuthorized(q.get('code')) } catch (err) { log(`github connect: ${err.message}`); return page('GitHub did not sign you in', 'Try Connect GitHub again from Quilt.') }
154
+ await store.setGithubLink(userId, { githubId: who.id, login: who.login })
155
+ // Connected for a workspace or an org (plan §2): its sessions use this connection before their people's own.
156
+ if (st.scope) {
157
+ const inst = Number(q.get('installation_id'))
158
+ await store.addGithubConnection({ scope: st.scope, scopeId: st.scopeId, connectedBy: userId, installationId: Number.isSafeInteger(inst) && inst > 0 ? inst : null })
159
+ }
160
+ cache.clear()
161
+ // The relay checks every session this person is in again now, so the card in their app clears as they come back.
162
+ nudge('/github/connected', { account: `person:${userId}` }, 'github connected')
163
+ // An organization that has its admins approve apps: the install is asked for, not done yet.
164
+ if (q.get('setup_action') === 'request') return page(`Asked, as @${who.login}`, 'The organization\'s admins have to approve Quilt on GitHub. As soon as they do, agents in your sessions on its repositories commit and merge on their own; nothing more to do on your side. You can close this tab.')
165
+ if (st.scope) return page(`Connected as @${who.login}`, `Sessions in this ${st.scope} use this connection now, before their people's own. You can close this tab.`)
166
+ return page(`Connected as @${who.login}`, 'Agents in your sessions can now commit and merge on the repositories you installed Quilt on, as far as each session\'s owner allows, and pushes reach the session within seconds. You can close this tab.')
167
+ }],
168
+ // A workspace's GitHub: its connections and access mode ('open', or 'repos' with the repositories it allows).
169
+ ['GET', /^\/v1\/workspaces\/([^/]+)\/github$/, async (req) => {
170
+ const { userId } = await person(req)
171
+ const { ws, admin } = await workspaceToManage(userId, req.url.split('/')[3], { manage: false })
172
+ let org = null
173
+ if (ws.orgId) {
174
+ const o = await store.orgById(ws.orgId)
175
+ const g = await orgGrantsFor(store, ws.orgId, `person:${userId}`)
176
+ const og = await store.orgGithub(ws.orgId)
177
+ org = { slug: o ? o.slug : '', name: o ? o.name : '', requireGithub: og.requireGithub, agentCommits: og.agentCommits, admin: !!(g && g.can('org', 'u')), connections: await connectionsOf('org', ws.orgId) }
178
+ }
179
+ return { ...(await store.workspaceGithub(ws.id)), connections: await connectionsOf('workspace', ws.id), admin, me: userId, ...(org ? { org } : {}) }
180
+ }],
181
+ ['PUT', /^\/v1\/workspaces\/([^/]+)\/github$/, async (req, b) => {
182
+ const { userId } = await person(req)
183
+ const { ws } = await workspaceToManage(userId, req.url.split('/')[3])
184
+ const patch = {}
185
+ if ('access' in b) {
186
+ const access = b.access === 'repos' ? 'repos' : b.access === 'open' ? 'open' : null
187
+ if (!access) throw new HttpError(400, 'Access is open or repos.')
188
+ const given = Array.isArray(b.repos) ? b.repos : []
189
+ const repos = [...new Set(given.map(cleanRepo))]
190
+ if (repos.includes(null)) throw new HttpError(400, 'Give repositories as owner/name.')
191
+ if (repos.length > MAX_REPOS) throw new HttpError(400, `List at most ${MAX_REPOS} repositories.`)
192
+ if (access === 'repos' && !repos.length) throw new HttpError(400, 'List the repositories this workspace may use.')
193
+ Object.assign(patch, { access, repos })
194
+ }
195
+ if ('agentCommits' in b) patch.agentCommits = agentCommitsGiven(b.agentCommits)
196
+ if (!Object.keys(patch).length) throw new HttpError(400, 'Give access (and repos) or agentCommits.')
197
+ await store.setWorkspaceGithub(ws.id, patch)
198
+ cache.clear()
199
+ if ('agentCommits' in patch) policyChanged()
200
+ return store.workspaceGithub(ws.id)
201
+ }],
202
+ ['DELETE', /^\/v1\/workspaces\/([^/]+)\/github\/connection$/, async (req, b) => {
203
+ const { userId } = await person(req)
204
+ const { ws, admin } = await workspaceToManage(userId, req.url.split('/')[3], { manage: false })
205
+ const whose = typeof b.connectedBy === 'string' && b.connectedBy ? b.connectedBy : userId
206
+ if (whose !== userId && !admin) throw new HttpError(403, 'only the workspace\'s admins remove others\' connections')
207
+ await store.removeGithubConnection({ scope: 'workspace', scopeId: ws.id, connectedBy: whose })
208
+ cache.clear()
209
+ return { ok: true }
210
+ }],
211
+ // An org's GitHub: its connections and the Require GitHub switch.
212
+ ['GET', /^\/v1\/orgs\/([^/]+)\/github$/, async (req) => {
213
+ const { userId } = await person(req)
214
+ const org = await orgAccess(store, userId, decodeURIComponent(req.url.split('/')[3]))
215
+ return { ...(await store.orgGithub(org.org.id)), connections: await connectionsOf('org', org.org.id), admin: org.can('org', 'u') }
216
+ }],
217
+ ['PUT', /^\/v1\/orgs\/([^/]+)\/github$/, async (req, b) => {
218
+ const { userId } = await person(req)
219
+ const org = await orgAccess(store, userId, decodeURIComponent(req.url.split('/')[3]))
220
+ org.need('org', 'u')
221
+ const patch = {}
222
+ if ('requireGithub' in b) {
223
+ if (typeof b.requireGithub !== 'boolean') throw new HttpError(400, 'Say requireGithub: true or false.')
224
+ patch.requireGithub = b.requireGithub
225
+ }
226
+ if ('agentCommits' in b) patch.agentCommits = agentCommitsGiven(b.agentCommits)
227
+ if (!Object.keys(patch).length) throw new HttpError(400, 'Give requireGithub or agentCommits.')
228
+ await store.setOrgGithub(org.org.id, patch)
229
+ cache.clear()
230
+ if ('agentCommits' in patch) policyChanged()
231
+ return store.orgGithub(org.org.id)
232
+ }],
233
+ ['DELETE', /^\/v1\/orgs\/([^/]+)\/github\/connection$/, async (req, b) => {
234
+ const { userId } = await person(req)
235
+ const org = await orgAccess(store, userId, decodeURIComponent(req.url.split('/')[3]))
236
+ const whose = typeof b.connectedBy === 'string' && b.connectedBy ? b.connectedBy : userId
237
+ if (whose !== userId) org.need('org', 'u')
238
+ await store.removeGithubConnection({ scope: 'org', scopeId: org.org.id, connectedBy: whose })
239
+ cache.clear()
240
+ return { ok: true }
241
+ }],
242
+ // GitHub's events, signed with the app's webhook secret (set by the API itself, github-app.js
243
+ // ensureWebhookSecret). Each is passed to the relay as a hint with what it needs; the relay
244
+ // trusts only the ref name and reads the truth from git.
245
+ ['POST', /^\/v1\/github\/webhook$/, async (req, raw) => {
246
+ const secret = hookSecret.value || ''
247
+ if (!cfg || !secret) throw new HttpError(503, 'GitHub webhooks are not set up on this server')
248
+ {
249
+ const sig = String(req.headers['x-hub-signature-256'] || '')
250
+ const want = 'sha256=' + crypto.createHmac('sha256', secret).update(raw).digest('hex')
251
+ if (sig.length !== want.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want))) throw new HttpError(401, 'bad signature')
252
+ }
253
+ const event = String(req.headers['x-github-event'] || '')
254
+ if (!['push', 'create', 'delete', 'pull_request', 'check_suite'].includes(event)) return { ok: true }
255
+ let e
256
+ try { e = JSON.parse(raw.toString('utf8')) } catch { throw new HttpError(400, 'bad json') }
257
+ const repo = e && e.repository && typeof e.repository.full_name === 'string' ? e.repository.full_name : ''
258
+ if (!repo || !relayHttp || !relaySecret) return { ok: true }
259
+ const tellHook = (path, body) => nudge(path, { repo: `github.com/${repo}`, ...body }, 'github webhook')
260
+ const sha = (v) => typeof v === 'string' && /^[0-9a-f]{40}$/.test(v) ? v : ''
261
+ if (event === 'pull_request') {
262
+ // A pull request changed (opened, closed, merged, synchronized): the relay looks at the one its tasks are in.
263
+ const pr = e.pull_request || {}
264
+ const number = Number.isInteger(pr.number) ? pr.number : (Number.isInteger(e.number) ? e.number : null)
265
+ const ref = (x) => x && typeof x.ref === 'string' ? x.ref : ''
266
+ if (number != null) await tellHook('/github/pull', { number, action: String(e.action || ''), ...(pr.merged === true ? { merged: true } : {}), ...(ref(pr.head) ? { head: ref(pr.head) } : {}), ...(ref(pr.base) ? { base: ref(pr.base) } : {}), ...(sha(pr.merge_commit_sha) ? { sha: pr.merge_commit_sha } : {}) })
267
+ return { ok: true }
268
+ }
269
+ if (event === 'check_suite') {
270
+ const cs = e.check_suite || {}
271
+ if (sha(cs.head_sha)) await tellHook('/github/checks', { sha: cs.head_sha, branch: typeof cs.head_branch === 'string' ? cs.head_branch : '', conclusion: String(cs.conclusion || '') })
272
+ return { ok: true }
273
+ }
274
+ if (event === 'create' || event === 'delete') {
275
+ if (e.ref_type !== 'branch' || typeof e.ref !== 'string' || !e.ref) return { ok: true }
276
+ await tellHook('/github/push', { ref: e.ref, ...(event === 'create' ? { created: true } : { deleted: true }) })
277
+ return { ok: true }
278
+ }
279
+ const ref = typeof e.ref === 'string' && e.ref.startsWith('refs/heads/') ? e.ref.slice('refs/heads/'.length) : ''
280
+ if (!ref) return { ok: true }
281
+ const after = e.deleted ? '' : sha(e.after)
282
+ await tellHook('/github/push', { ref, before: sha(e.before), after, sha: after, forced: !!e.forced, deleted: !!e.deleted })
283
+ return { ok: true }
284
+ }, { raw: true, maxBody: 25 * 1024 * 1024 }],
285
+ // The relay: whether git is on for everyone in a session, and what agents may push there, when a rule
286
+ // above the session says (docs/superpowers/specs/2026-10-17-git-for-everyone.md): its org's, the teams'
287
+ // its owner is on (in the org) and its workspace's. Each rule is a ceiling (off < branches < any), so the
288
+ // strictest is the one that holds; at equal strictness the broader is named, since that's the one to change.
289
+ // { agentCommits: '' } when none is set: the session's owner decides.
290
+ ['POST', /^\/v1\/relay\/session-policy$/, async (req, b) => {
291
+ fromRelay(req)
292
+ const room = typeof b.room === 'string' && ROOM.test(b.room) ? b.room : ''
293
+ if (!room) throw new HttpError(400, 'give the room')
294
+ const session = await store.sessionByRoom(room).catch(() => null)
295
+ const ws = session && session.workspaceId ? await store.workspaceById(session.workspaceId).catch(() => null) : null
296
+ if (!ws) return { agentCommits: '' }
297
+ const rules = []
298
+ if (ws.orgId) {
299
+ const o = await store.orgGithub(ws.orgId)
300
+ if (o.agentCommits) { const org = await store.orgById(ws.orgId).catch(() => null); rules.push({ agentCommits: o.agentCommits, by: 'org', name: (org && org.name) || '' }) }
301
+ const owner = ACCOUNT.exec(session.ownerAccount || '')
302
+ const member = owner ? await store.memberOf(ws.orgId, owner[1]).catch(() => null) : null
303
+ if (member) for (const r of await store.teamGitRules(member.id)) rules.push({ agentCommits: r.agentCommits, by: 'team', name: r.name })
304
+ }
305
+ const w = await store.workspaceGithub(ws.id)
306
+ if (w.agentCommits) rules.push({ agentCommits: w.agentCommits, by: 'workspace', name: ws.name || '' })
307
+ if (!rules.length) return { agentCommits: '' }
308
+ const rank = { off: 0, branches: 1, any: 2 }
309
+ // Stable: the first of the strictest, and rules are in order broadest first.
310
+ return rules.reduce((best, r) => rank[r.agentCommits] < rank[best.agentCommits] ? r : best)
311
+ }],
312
+ // The relay: one live session per repository and branch (plan §8). Leases lapse LEASE_TTL_MS after the last heartbeat.
313
+ ['POST', /^\/v1\/relay\/leases\/(take|heartbeat|release)$/, async (req, b) => {
314
+ fromRelay(req)
315
+ const what = new URL(req.url, 'http://x').pathname.split('/').pop()
316
+ const repo = String(b.repo || ''); const branch = String(b.branch || ''); const room = String(b.room || '')
317
+ if (!/^[A-Za-z0-9.-]{1,100}\/[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/.test(repo) || !branch || branch.length > 255 || !/^[A-Za-z0-9_-]{1,64}$/.test(room)) throw new HttpError(400, 'give repo (host/owner/name), branch and room')
318
+ const ttlMs = Math.max(1000, Math.min(10 * 60_000, Number(b.ttlMs) || LEASE_TTL_MS))
319
+ const generation = Number.isInteger(b.generation) ? b.generation : 0
320
+ if (what === 'take') return store.takeLease({ repo, branch, room, holder: String(b.holder || '').slice(0, 100), ttlMs })
321
+ if (what === 'heartbeat') return store.heartbeatLease({ repo, branch, room, generation, ttlMs })
322
+ return store.releaseLease({ repo, branch, room, generation })
323
+ }],
324
+ // The relay: which GitHub accounts the session's people linked (login and id), for commit authorship
325
+ // (relay-outbound.js: a person's commits carry their GitHub noreply address). Never a token.
326
+ ['POST', /^\/v1\/relay\/github-logins$/, async (req, b) => {
327
+ fromRelay(req)
328
+ const given = Array.isArray(b.accounts) ? b.accounts : []
329
+ const accounts = [...new Set(given.map((x) => { const m = ACCOUNT.exec(String(x || '')); return m ? m[1] : null }).filter(Boolean))].slice(0, 200)
330
+ const logins = {}
331
+ for (const id of accounts) { const l = await store.githubLink(id); if (l) logins[`person:${id}`] = { login: l.login, id: l.githubId } }
332
+ return { logins }
333
+ }],
334
+ // The relay: credentials for one repository, for the first of the session's people whose linked
335
+ // GitHub account may do what is asked there (write: commit, merge; read: fetch, clone).
336
+ ['POST', /^\/v1\/relay\/github-token$/, async (req, b) => {
337
+ fromRelay(req)
338
+ need()
339
+ const access = b.access === 'read' ? 'read' : 'write'
340
+ const given = Array.isArray(b.accounts) ? b.accounts : b.account ? [b.account] : []
341
+ const members = [...new Set(given.map((x) => { const m = ACCOUNT.exec(String(x || '')); return m ? m[1] : null }).filter(Boolean))].slice(0, 50)
342
+ const [owner, name] = String(b.repo || '').split('/')
343
+ if (!members.length || !PART.test(owner || '') || !PART.test(name || '')) throw new HttpError(400, 'give the session\'s people (person:<id>) and the repository (owner/name)')
344
+ // `room`: the session asking, for its workspace's and org's connections and access mode; `actingFor`: who the token is for.
345
+ const room = typeof b.room === 'string' && ROOM.test(b.room) ? b.room : ''
346
+ const actingFor = typeof b.actingFor === 'string' && ACTOR.test(b.actingFor) ? b.actingFor : ''
347
+ const cover = await coverFor({ room, accounts: members, actingFor, owner, name })
348
+ if (cover.refused) return cover.refused
349
+ const accounts = cover.ids
350
+ const linked = []
351
+ for (const id of accounts) { const l = await store.githubLink(id); if (l) linked.push({ id, ...l }) }
352
+ if (!linked.length) return { state: 'not-connected', ...(cover.require ? { require: true, actingFor } : {}) }
353
+ // `fresh`: a token of its own for one agent (minted anew, not kept): it can be revoked on its own.
354
+ const fresh = b.fresh === true
355
+ const key = (id) => `${id}\0${owner}/${name}\0${access}`.toLowerCase()
356
+ if (!fresh) for (const l of linked) { const hit = cache.get(key(l.id)); if (hit && hit.expiresAt - now() > 5 * 60 * 1000) return { state: 'ok', ...hit } }
357
+ try {
358
+ const inst = await app.installation(owner, name)
359
+ if (!inst) return { state: 'not-installed', login: linked[0].login, installUrl: `https://github.com/apps/${cfg.slug}/installations/new` }
360
+ // One token for the role checks (read is enough to ask), then one with what was asked for.
361
+ const probe = await app.repoToken(inst, name, { access: 'read' })
362
+ let first = null
363
+ for (const l of linked) {
364
+ const role = await app.role(probe.token, owner, name, l.login)
365
+ const ok = access === 'read' ? READ_ROLES.includes(role) : WRITE_ROLES.includes(role)
366
+ if (!first) first = { login: l.login, role }
367
+ if (!ok) continue
368
+ const t = access === 'read' && !fresh ? probe : await app.repoToken(inst, name, { access })
369
+ const out = { token: t.token, expiresAt: t.expiresAt, login: l.login }
370
+ if (!fresh) cache.set(key(l.id), out)
371
+ return { state: 'ok', ...out }
372
+ }
373
+ return { state: 'no-access', login: first.login, role: first.role }
374
+ } catch (err) {
375
+ if (err instanceof GitHubAppError) { log(`github token for ${owner}/${name}: ${err.message}`); return { state: 'error', error: err.message } }
376
+ throw err
377
+ }
378
+ }]
379
+ ]
380
+ }
@@ -0,0 +1,70 @@
1
+ // Who gets what in a session: the owner gives someone (an account) an access type,
2
+ // optionally narrowed. The API is where access lives; passes carry it to the relay.
3
+ import { HttpError } from '../http.js'
4
+ import { cleanTighten } from '../../session-access.js'
5
+ import { ownType, grantView } from '../access.js'
6
+
7
+ export const ROOM = /^[A-Za-z0-9_-]{1,64}$/
8
+ export const ACCOUNT = /^(person|agent):[A-Za-z0-9_-]{1,64}$/
9
+ const EMAIL_KEY = /^[^\s@,;<>"()\\]+@[^\s@,;<>"()\\]+$/
10
+ export const NOT_YET = "That session hasn't reached heyquilt.com yet. Try again in a minute."
11
+
12
+ /**
13
+ * The session's owner, from their request: { me: 'person:<id>', userId, session }. 404 for a
14
+ * session the API hasn't heard of (the relay reports new ones within seconds), 403 for anyone else.
15
+ */
16
+ export async function sessionOwner (store, person, req, room, forbidden = 'Only the session owner can change who gets in.') {
17
+ const { userId } = await person(req)
18
+ const me = `person:${userId}`
19
+ if (!ROOM.test(room)) throw new HttpError(404, NOT_YET)
20
+ const session = await store.sessionByRoom(room)
21
+ if (!session) throw new HttpError(404, NOT_YET)
22
+ if (session.ownerAccount !== me) throw new HttpError(403, forbidden)
23
+ return { me, userId, session }
24
+ }
25
+
26
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
27
+
28
+ /**
29
+ * The owner letting an agent into a session again (a grant) also ends keeping it out of that
30
+ * session, which removing it from a session in a workspace starts. Only with workspaces on.
31
+ */
32
+ export async function letAgentBackIn (store, room, account, workspaces, log = () => {}) {
33
+ if (!workspaces || !String(account).startsWith('agent:') || !UUID.test(account.slice(6))) return
34
+ // The grant is already written: a failure here is logged, never the request's answer.
35
+ try { await store.removeSessionAgentExclusion(room, account.slice(6)) } catch (err) { log(`could not end keeping ${account} out of ${room}: ${err.message}`) }
36
+ }
37
+
38
+ export function grantRoutes ({ store, person, now = Date.now, workspaces = false, log = () => {} }) {
39
+ return [
40
+ ['GET', /^\/v1\/sessions\/([^/]+)\/grants$/, async (req, body, [room]) => {
41
+ await sessionOwner(store, person, req, room)
42
+ return { grants: await Promise.all((await store.listGrants(room)).map((g) => grantView(store, g))) }
43
+ }],
44
+
45
+ ['PUT', /^\/v1\/sessions\/([^/]+)\/grants\/([^/]+)$/, async (req, body, [room, account]) => {
46
+ const { me } = await sessionOwner(store, person, req, room)
47
+ if (!ACCOUNT.test(account)) throw new HttpError(400, 'Grants are for people and agents (person:<id> or agent:<id>).')
48
+ if (account === me) throw new HttpError(400, 'The owner always has full access.')
49
+ const type = await ownType(store, me, body.typeId)
50
+ if (!type) throw new HttpError(400, 'no such access type')
51
+ let tighten
52
+ try { tighten = cleanTighten(body.tighten) } catch (err) { throw new HttpError(400, err.message) }
53
+ const grant = await store.putGrant({ room, account, typeId: type.id, tighten, grantedBy: me })
54
+ await letAgentBackIn(store, room, account, workspaces, log)
55
+ return { grant: await grantView(store, grant) }
56
+ }],
57
+
58
+ ['DELETE', /^\/v1\/sessions\/([^/]+)\/grants\/([^/]+)$/, async (req, body, [room, account]) => {
59
+ await sessionOwner(store, person, req, room)
60
+ // An account, or an email invite's grant (left by an invite that expired, say).
61
+ const email = account.startsWith('email:') ? account.slice('email:'.length) : ''
62
+ if (!ACCOUNT.test(account) && !(email && EMAIL_KEY.test(email))) throw new HttpError(400, 'no such account')
63
+ await store.deleteGrant(room, account)
64
+ // Their open invite would still list as waiting, for access they no longer have.
65
+ const open = await store.openSessionInvite(room, email ? { email } : { account }, now())
66
+ if (open) await store.cancelSessionInvite(open.id)
67
+ return { ok: true }
68
+ }]
69
+ ]
70
+ }
@@ -0,0 +1,188 @@
1
+ // Joining an org: email invites (the API sends them) and domain join requests.
2
+ import { HttpError, needId, stripInvisible } from '../http.js'
3
+ import { orgAccess } from '../org-access.js'
4
+ import { newToken, hashToken } from '../tokens.js'
5
+ import { emailDomain, isPublicDomain } from '../domains.js'
6
+ import { inviteEmail } from '../invite-email.js'
7
+
8
+ const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000
9
+ const GONE = {
10
+ accepted: 'this invite was already used',
11
+ cancelled: 'this invite was cancelled',
12
+ expired: 'this invite has expired; ask for a new one'
13
+ }
14
+ // One address, no separators or angle brackets/quotes/parens a mail header could
15
+ // smuggle through, and exactly one '@' (so "a@x.com,b@y.com" can't sneak a second
16
+ // recipient past nodemailer). `emailDomain` still checks the part after '@' is a real domain.
17
+ const STRICT_EMAIL = /^[^\s@,;<>"()\\]+@[^\s@,;<>"()\\]+$/
18
+
19
+ export function inviteRoutes ({ store, user, now, site, mailer, log, limit, limitSend }) {
20
+ const orgFor = async (req, slug) => { const u = await user(req); return { u, ...(await orgAccess(store, u.userId, slug)) } }
21
+ const statusOf = (i) => (i.acceptedAt ? 'accepted' : i.cancelledAt ? 'cancelled' : i.expiresAt < now() ? 'expired' : 'pending')
22
+ const inviteView = (i, role) => ({ id: i.id, email: i.email, roleId: i.roleId, role, expiresAt: i.expiresAt, createdAt: i.createdAt, expired: i.expiresAt < now() })
23
+
24
+ // The domain of the caller's confirmed email, unless it belongs to a public mail provider.
25
+ async function confirmedDomain (userId) {
26
+ const mine = await store.userEmail(userId)
27
+ const d = mine?.confirmed ? emailDomain(mine.email) : ''
28
+ return d && !isPublicDomain(d) ? d : null
29
+ }
30
+
31
+ // A single, real-looking address; never a list, and never one a mail header could split.
32
+ function cleanEmail (raw) {
33
+ const email = String(raw || '').trim().toLowerCase()
34
+ if (!email || email.length > 254 || !STRICT_EMAIL.test(email) || !emailDomain(email)) throw new HttpError(400, "that email doesn't look right")
35
+ return email
36
+ }
37
+
38
+ // Someone already in the org, by their signed-in email — so an invite (or its
39
+ // acceptance) can't hand a second membership, or a second role, to the same person.
40
+ async function memberWithEmail (orgId, email) {
41
+ for (const m of await store.listMembers(orgId)) {
42
+ // Agent members have no userId (and so no signed-in email) to collide with.
43
+ if (!m.userId) continue
44
+ const e = await store.userEmail(m.userId)
45
+ if (String(e?.email || '').toLowerCase() === email) return m
46
+ }
47
+ return null
48
+ }
49
+
50
+ // The link only ever travels by email; only its hash is stored.
51
+ async function mail (a, invite, role, token) {
52
+ const inviter = await store.profile(a.u.userId)
53
+ // A profile name is free text; strip anything that could hide or reorder
54
+ // characters before it lands in the invite email's subject line.
55
+ const inviterName = stripInvisible(inviter?.name || '').join('').trim() || undefined
56
+ const msg = inviteEmail({ orgName: a.org.name, inviterName, roleName: role.name, link: `${site}/invite/${token}` })
57
+ try {
58
+ await mailer.send({ to: invite.email, ...msg })
59
+ } catch (err) {
60
+ log(`invite email failed: ${err?.stack || err?.message || err}`)
61
+ throw new HttpError(502, "the invite was saved but the email didn't send; try Resend")
62
+ }
63
+ }
64
+
65
+ async function openInvite (a, id) {
66
+ const i = await store.inviteById(a.org.id, needId(id, 'invite'))
67
+ if (!i || i.acceptedAt || i.cancelledAt) throw new HttpError(404, 'no such invite')
68
+ return i
69
+ }
70
+
71
+ return [
72
+ ['GET', /^\/v1\/orgs\/([^/]+)\/invites$/, async (req, body, [slug]) => {
73
+ const a = await orgFor(req, slug)
74
+ a.need('invites', 'r')
75
+ const roles = new Map((await store.listRoles(a.org.id)).map((r) => [r.id, r.name]))
76
+ const [invites, requests] = await Promise.all([store.listInvites(a.org.id), store.listJoinRequests(a.org.id)])
77
+ return {
78
+ invites: invites.map((i) => inviteView(i, roles.get(i.roleId) || null)),
79
+ requests: requests.map((r) => ({ id: r.id, userId: r.userId, name: r.name, email: r.email, createdAt: r.createdAt }))
80
+ }
81
+ }],
82
+
83
+ ['POST', /^\/v1\/orgs\/([^/]+)\/invites$/, async (req, body, [slug]) => {
84
+ const a = await orgFor(req, slug)
85
+ a.need('invites', 'c')
86
+ limitSend(a.u.userId)
87
+ const email = cleanEmail(body.email)
88
+ const role = await a.assignable(body.roleId)
89
+ if (await memberWithEmail(a.org.id, email)) throw new HttpError(409, 'that person is already a member')
90
+ // Never cancel someone else's invite just by sending a new one to the same
91
+ // address: a narrow inviter (User invites: Create only) could otherwise
92
+ // swap out an owner's pending Admin invite for a Member one.
93
+ if ((await store.listInvites(a.org.id)).some((old) => old.email === email && statusOf(old) === 'pending')) {
94
+ throw new HttpError(409, 'That address already has an open invite. Resend or cancel it.')
95
+ }
96
+ const token = newToken('qi_')
97
+ const invite = await store.createInvite({ orgId: a.org.id, email, roleId: role.id, tokenHash: hashToken(token), invitedBy: a.u.userId, expiresAt: now() + INVITE_TTL_MS })
98
+ await mail(a, invite, role, token)
99
+ return { invite: inviteView(invite, role.name) }
100
+ }],
101
+
102
+ ['POST', /^\/v1\/orgs\/([^/]+)\/invites\/([^/]+)\/resend$/, async (req, body, [slug, id]) => {
103
+ const a = await orgFor(req, slug)
104
+ a.need('invites', 'u')
105
+ limitSend(a.u.userId)
106
+ const invite = await openInvite(a, id)
107
+ const role = await store.roleById(a.org.id, invite.roleId)
108
+ if (!role) throw new HttpError(404, 'no such role')
109
+ if (!a.covers(role.grants)) throw new HttpError(403, 'you can only resend invites within your own permissions')
110
+ const token = newToken('qi_')
111
+ const fresh = await store.updateInvite(invite.id, { tokenHash: hashToken(token), expiresAt: now() + INVITE_TTL_MS })
112
+ await mail(a, fresh, role, token)
113
+ return { invite: inviteView(fresh, role.name) }
114
+ }],
115
+
116
+ ['DELETE', /^\/v1\/orgs\/([^/]+)\/invites\/([^/]+)$/, async (req, body, [slug, id]) => {
117
+ const a = await orgFor(req, slug)
118
+ a.need('invites', 'd')
119
+ await store.updateInvite((await openInvite(a, id)).id, { cancelledAt: now() })
120
+ return { ok: true }
121
+ }],
122
+
123
+ ['GET', /^\/v1\/invites\/([^/]+)$/, async (req, body, [token]) => {
124
+ await user(req)
125
+ limit(req)
126
+ const invite = await store.inviteByToken(hashToken(token))
127
+ if (!invite) throw new HttpError(404, 'no such invite')
128
+ const org = await store.orgById(invite.orgId)
129
+ const role = await store.roleById(invite.orgId, invite.roleId)
130
+ return { org: { name: org.name, slug: org.slug }, email: invite.email, role: role?.name || null, status: statusOf(invite) }
131
+ }],
132
+
133
+ ['POST', /^\/v1\/invites\/accept$/, async (req, body) => {
134
+ const u = await user(req)
135
+ limit(req)
136
+ const invite = await store.inviteByToken(hashToken(body.token))
137
+ if (!invite) throw new HttpError(404, 'no such invite')
138
+ const status = statusOf(invite)
139
+ if (status !== 'pending') throw new HttpError(410, GONE[status])
140
+ // Only the person it was sent to, once they've proved they own the address.
141
+ const mine = await store.userEmail(u.userId)
142
+ if (String(mine?.email || '').toLowerCase() !== invite.email) throw new HttpError(403, `this invite is for ${invite.email}; sign in with that address`)
143
+ if (!mine.confirmed) throw new HttpError(403, 'confirm your email address first, then open the invite again')
144
+ if (await store.memberOf(invite.orgId, u.userId)) throw new HttpError(409, "You're already in this org.")
145
+ if (!await store.claimInvite(invite.id)) throw new HttpError(410, GONE.accepted)
146
+ const org = await store.orgById(invite.orgId)
147
+ await store.addMember({ orgId: org.id, userId: u.userId, roleId: invite.roleId })
148
+ return { org: { name: org.name, slug: org.slug } }
149
+ }],
150
+
151
+ ['GET', /^\/v1\/orgs\/discover$/, async (req) => {
152
+ const u = await user(req)
153
+ const domain = await confirmedDomain(u.userId)
154
+ if (!domain) return { domain: null, orgs: [] }
155
+ const [orgs, mine, asked] = await Promise.all([store.orgsByDomain(domain), store.orgsForUser(u.userId), store.joinRequestsForUser(u.userId)])
156
+ const joined = new Set(mine.map((o) => o.id))
157
+ const pending = new Set(asked.filter((r) => r.status === 'pending').map((r) => r.orgId))
158
+ return { domain, orgs: orgs.filter((o) => !joined.has(o.id)).map((o) => ({ name: o.name, slug: o.slug, requested: pending.has(o.id) })) }
159
+ }],
160
+
161
+ ['POST', /^\/v1\/orgs\/([^/]+)\/requests$/, async (req, body, [slug]) => {
162
+ const u = await user(req)
163
+ limit(req)
164
+ const org = await store.orgBySlug(String(slug).toLowerCase())
165
+ const domain = await confirmedDomain(u.userId)
166
+ // One answer for a missing org and one you can't ask to join, so addresses can't be probed.
167
+ if (!org || !org.domainRequests || !domain || org.domain !== domain) throw new HttpError(404, 'no such org')
168
+ if (await store.memberOf(org.id, u.userId)) throw new HttpError(409, "you're already in this org")
169
+ const mine = await store.userEmail(u.userId)
170
+ const r = await store.createJoinRequest({ orgId: org.id, userId: u.userId, email: mine.email.toLowerCase() })
171
+ return { request: { id: r.id, status: r.status } }
172
+ }],
173
+
174
+ ['POST', /^\/v1\/orgs\/([^/]+)\/requests\/([^/]+)$/, async (req, body, [slug, id]) => {
175
+ const a = await orgFor(req, slug)
176
+ if (typeof body.approve !== 'boolean') throw new HttpError(400, 'approve must be true or false')
177
+ const approve = body.approve
178
+ // Approving is User invites: Create; denying is User invites: Delete.
179
+ a.need('invites', approve ? 'c' : 'd')
180
+ const r = await store.joinRequestById(a.org.id, needId(id, 'request'))
181
+ if (!r || r.status !== 'pending') throw new HttpError(404, 'no such request')
182
+ const role = approve ? await a.assignable(body.roleId) : null
183
+ if (!await store.decideJoinRequest(r.id, { status: approve ? 'approved' : 'denied', decidedBy: a.u.userId })) throw new HttpError(404, 'no such request')
184
+ if (approve) await store.addMember({ orgId: a.org.id, userId: r.userId, roleId: role.id })
185
+ return { status: approve ? 'approved' : 'denied' }
186
+ }]
187
+ ]
188
+ }