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,76 @@
1
+ // The relay reports who is in which session and when (src/presence.js on the relay). It
2
+ // signs in with RELAY_API_SECRET. Each event applies once: replays are ignored.
3
+ import crypto from 'node:crypto'
4
+ import { HttpError, UUID, stripInvisible } from '../http.js'
5
+ import { DAY_MS } from '../activity.js'
6
+ import { cleanSessionName } from '../../session-name.js'
7
+ import { END_REASONS, ACTIONS } from '../../presence.js'
8
+
9
+ export const MAX_EVENTS = 500
10
+ // 500 events of a few hundred bytes each: more than the API's usual 16 KB.
11
+ export const PRESENCE_BODY = 512 * 1024
12
+ export const KEEP_MS = 365 * DAY_MS
13
+ export const SEEN_MS = 7 * DAY_MS
14
+ const ROOM = /^[A-Za-z0-9_-]{1,64}$/
15
+ const ACCOUNT = /^(person|agent):[A-Za-z0-9_-]{1,64}$/
16
+
17
+ const VIA = ['app', 'hosted']
18
+ /** A short label: printable, at most `n` characters. */
19
+ const label = (x, n) => stripInvisible(x).slice(0, n).join('').replace(/[\u0000-\u001f\u007f]/g, '').trim()
20
+
21
+ const digest = (s) => crypto.createHash('sha256').update(String(s)).digest()
22
+
23
+ /** An event as the store takes it, or null when it isn't one (it is skipped, not retried). */
24
+ export function cleanEvent (e, now) {
25
+ if (!e || typeof e !== 'object' || !UUID.test(String(e.id)) || !ROOM.test(String(e.room)) || !Number.isFinite(e.at)) return null
26
+ // The relay's clock may run ahead; nothing is recorded in the future.
27
+ const at = Math.min(e.at, now)
28
+ if (at < now - KEEP_MS) return null
29
+ const base = { id: String(e.id).toLowerCase(), type: e.type, room: e.room, at }
30
+ if (e.type === 'start') {
31
+ if (!ACCOUNT.test(String(e.account))) return null
32
+ const name = stripInvisible(e.name).slice(0, 64).join('').trim() || 'Quilt user'
33
+ const how = { ...(VIA.includes(e.via) ? { via: e.via } : {}), ...(label(e.tool, 40) ? { tool: label(e.tool, 40) } : {}) }
34
+ return { ...base, account: e.account, name, owner: e.owner === true, ...how }
35
+ }
36
+ if (e.type === 'end') {
37
+ if (!ACCOUNT.test(String(e.account)) || !UUID.test(String(e.start))) return null
38
+ return { ...base, account: e.account, start: String(e.start).toLowerCase(), ...(END_REASONS.includes(e.reason) ? { reason: e.reason } : {}) }
39
+ }
40
+ if (e.type === 'act') {
41
+ if (!ACCOUNT.test(String(e.account)) || !UUID.test(String(e.start)) || !ACTIONS.includes(e.action)) return null
42
+ return { ...base, account: e.account, start: String(e.start).toLowerCase(), action: e.action, target: label(e.target, 300) }
43
+ }
44
+ if (e.type === 'name') {
45
+ const name = cleanSessionName(e.name)
46
+ return name ? { ...base, name } : null
47
+ }
48
+ return null
49
+ }
50
+
51
+ export function relayRoutes ({ store, now, log, bearer, relaySecret }) {
52
+ let prunedAt = 0
53
+ const fromRelay = (req) => {
54
+ if (!relaySecret) throw new HttpError(503, 'presence is not set up on this server')
55
+ // Hashed first so the comparison takes the same time whatever was sent.
56
+ if (!crypto.timingSafeEqual(digest(bearer(req)), digest(relaySecret))) throw new HttpError(401, 'only the relay may report presence')
57
+ }
58
+
59
+ return [
60
+ ['POST', /^\/v1\/relay\/presence$/, async (req, body) => {
61
+ fromRelay(req)
62
+ if (!Array.isArray(body.events)) throw new HttpError(400, 'events must be a list')
63
+ if (body.events.length > MAX_EVENTS) throw new HttpError(413, `at most ${MAX_EVENTS} events at a time`)
64
+ const t = now()
65
+ const events = body.events.map((e) => cleanEvent(e, t)).filter(Boolean)
66
+ const applied = events.length ? await store.ingestPresence(events, t) : 0
67
+ // Visits older than 12 months go once a day, when the relay next reports (the API has no timer).
68
+ if (t - prunedAt >= DAY_MS) {
69
+ prunedAt = t
70
+ await store.pruneActivity({ before: t - KEEP_MS, seenBefore: t - SEEN_MS })
71
+ .catch((err) => log(`could not prune session activity: ${err?.message || err}`))
72
+ }
73
+ return { ok: true, applied, skipped: body.events.length - events.length }
74
+ }, { maxBody: PRESENCE_BODY }]
75
+ ]
76
+ }
@@ -0,0 +1,119 @@
1
+ // Inviting people and agents to a session, as an access type: by email, or someone the
2
+ // owner has worked with. The grant is made up front (keyed by the account, or by the
3
+ // email until someone signs in with it), so they get straight in. The invite link holds
4
+ // the room secret: it goes into the email, and is kept with the invite only so the person
5
+ // it was sent to sees it in their Quilt (GET /v1/me/invites); the owner's views never show it.
6
+ import { HttpError, UUID, stripInvisible } from '../http.js'
7
+ import { emailDomain } from '../domains.js'
8
+ import { sessionInviteEmail } from '../invite-email.js'
9
+ import { parseInvite } from '../../ui/invite.js'
10
+ import { ownType, typeOfGrant } from '../access.js'
11
+ import { sessionOwner, ACCOUNT, letAgentBackIn } from './grants.js'
12
+ import { collaboratorsOf } from './sessions.js'
13
+
14
+ export const SESSION_INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000
15
+ const FORBIDDEN = 'Only the session owner can invite people.'
16
+ // One address, nothing a mail header could split (the same rule as org invites).
17
+ const STRICT_EMAIL = /^[^\s@,;<>"()\\]+@[^\s@,;<>"()\\]+$/
18
+
19
+ export function sessionInviteRoutes ({ store, person, now, site, mailer, log, limitSend, workspaces = false }) {
20
+ const statusOf = (i) => (i.usedAt ? 'used' : i.cancelledAt ? 'cancelled' : i.expiresAt <= now() ? 'expired' : 'waiting')
21
+ // The invite comes first, then its grant: one that loses a race to another open invite for
22
+ // the same address or account (the database's unique index) changes nothing.
23
+ async function createInvite (fields, duplicateMessage, grant) {
24
+ let invite
25
+ try {
26
+ invite = await store.createSessionInvite({ ...fields, at: now() })
27
+ } catch (err) {
28
+ if (err.code === '23505') throw new HttpError(409, duplicateMessage)
29
+ throw err
30
+ }
31
+ // No grant, no invite: an open one would block inviting them again.
32
+ try { await store.putGrant(grant) } catch (err) { await store.cancelSessionInvite(invite.id).catch(() => {}); throw err }
33
+ await letAgentBackIn(store, grant.room, grant.account, workspaces, log)
34
+ return invite
35
+ }
36
+ // An account invite shows the name the owner saw, never the person's email.
37
+ async function view (i) {
38
+ const type = await typeOfGrant(store, { typeId: i.typeId })
39
+ return { id: i.id, ...(i.email ? { email: i.email } : { account: i.account, name: i.accountName }), typeId: type.id, typeName: type.name, status: statusOf(i), createdAt: i.createdAt, expiresAt: i.expiresAt }
40
+ }
41
+
42
+ function cleanEmail (raw) {
43
+ const email = String(raw || '').trim().toLowerCase()
44
+ if (!email || email.length > 254 || !STRICT_EMAIL.test(email) || !emailDomain(email)) throw new HttpError(400, "That email doesn't look right.")
45
+ return email
46
+ }
47
+
48
+ async function send (to, { userId, session, link }) {
49
+ const inviter = await store.profile(userId)
50
+ const inviterName = stripInvisible(inviter?.name || '').join('').trim() || undefined
51
+ const msg = sessionInviteEmail({ inviterName, sessionName: session.name || 'Untitled session', link, site })
52
+ try {
53
+ await mailer.send({ to, ...msg })
54
+ } catch (err) {
55
+ log(`session invite email failed: ${err?.stack || err?.message || err}`)
56
+ throw new HttpError(502, "The invite was saved, but the email didn't send. Cancel it and invite them again.")
57
+ }
58
+ }
59
+
60
+ return [
61
+ ['GET', /^\/v1\/sessions\/([^/]+)\/invites$/, async (req, body, [room]) => {
62
+ await sessionOwner(store, person, req, room, FORBIDDEN)
63
+ return { invites: await Promise.all((await store.listSessionInvites(room)).map(view)) }
64
+ }],
65
+
66
+ ['POST', /^\/v1\/sessions\/([^/]+)\/invites$/, async (req, body, [room]) => {
67
+ const owner = await sessionOwner(store, person, req, room, FORBIDDEN)
68
+ limitSend(owner.userId)
69
+ const type = await ownType(store, owner.me, body.typeId)
70
+ if (!type) throw new HttpError(400, 'no such access type')
71
+ let inv = null
72
+ try { inv = parseInvite(body.link, { allowRelay: () => true }) } catch {}
73
+ if (!inv || inv.room !== room) throw new HttpError(400, "Send this session's invite link.")
74
+ const link = String(body.link).trim()
75
+ const to = body.to && typeof body.to === 'object' ? body.to : {}
76
+
77
+ if (to.email !== undefined) {
78
+ const email = cleanEmail(to.email)
79
+ const taken = 'That address already has an open invite. Cancel it first.'
80
+ if (await store.openSessionInvite(room, { email }, now())) throw new HttpError(409, taken)
81
+ const invite = await createInvite({ room, email, typeId: type.id, invitedBy: owner.me, expiresAt: now() + SESSION_INVITE_TTL_MS, link }, taken,
82
+ { room, account: `email:${email}`, typeId: type.id, grantedBy: owner.me })
83
+ await send(email, { ...owner, link })
84
+ return { invite: await view(invite) }
85
+ }
86
+
87
+ const account = String(to.account || '')
88
+ if (!ACCOUNT.test(account)) throw new HttpError(400, 'Invite someone by email, or someone you have worked with.')
89
+ if (account === owner.me) throw new HttpError(400, "That's you.")
90
+ const who = (await collaboratorsOf(store, owner.me, now())).find((c) => c.account === account)
91
+ if (!who) throw new HttpError(404, "You can invite people you've worked with, or anyone by email.")
92
+ const taken = 'They already have an open invite. Cancel it first.'
93
+ if (await store.openSessionInvite(room, { account }, now())) throw new HttpError(409, taken)
94
+ // Inviting them again would overwrite the access they have, and cancelling that invite
95
+ // would then take it all away.
96
+ if (await store.grantFor(room, account)) throw new HttpError(409, 'They already have access to this session. Change it in Session settings.')
97
+ const invite = await createInvite({ room, account, accountName: who.name, typeId: type.id, invitedBy: owner.me, expiresAt: now() + SESSION_INVITE_TTL_MS, link }, taken,
98
+ { room, account, typeId: type.id, grantedBy: owner.me })
99
+ // A person hears about it at their sign-in email (looked up here, never sent back). An
100
+ // agent has no email: its grant just lets it straight in.
101
+ if (who.kind === 'person') {
102
+ const mail = await store.userEmail(account.slice('person:'.length))
103
+ if (mail?.confirmed && mail.email) await send(mail.email, { ...owner, link })
104
+ }
105
+ return { invite: await view(invite) }
106
+ }],
107
+
108
+ // Cancelling also takes back the grant the invite made, unless someone already used it.
109
+ ['DELETE', /^\/v1\/sessions\/([^/]+)\/invites\/([^/]+)$/, async (req, body, [room, id]) => {
110
+ await sessionOwner(store, person, req, room, FORBIDDEN)
111
+ const invite = UUID.test(id) ? await store.sessionInviteById(room, id) : null
112
+ if (!invite) throw new HttpError(404, 'no such invite')
113
+ if (!await store.cancelSessionInvite(invite.id)) throw new HttpError(409, 'That invite was already used or cancelled.')
114
+ // Unless an open invite for the same address or account needs it, even one made just now.
115
+ await store.deleteUnusedGrant(room, invite.email ? `email:${invite.email}` : invite.account, now())
116
+ return { ok: true }
117
+ }]
118
+ ]
119
+ }
@@ -0,0 +1,92 @@
1
+ // Your sessions on the dashboard: where you've been, your time there, and who you
2
+ // worked with (activity.js does the sums). Only sessions you were in, and only people
3
+ // who were there at the same time as you.
4
+ import { HttpError, Raw } from '../http.js'
5
+ import { auditTrail, auditCsv, MAX_ACTIONS } from '../audit.js'
6
+ import { summarize, collaborators, myVisits, isTimeZone, weekStart, monthStart, MAX_SESSIONS } from '../activity.js'
7
+ import { cleanSessionName, BAD_SESSION_NAME } from '../../session-name.js'
8
+
9
+ const ROOM = /^[A-Za-z0-9_-]{1,64}$/
10
+ const NO_SESSION = 'no such session'
11
+
12
+ /** An account's dashboard: its sessions, with the people in each, and the totals. */
13
+ export async function overviewOf (store, account, tz, t) {
14
+ // The latest sessions, plus every one active this week or month, for the totals.
15
+ const since = Math.min(weekStart(t, tz), monthStart(t, tz))
16
+ const sessions = await store.accountSessions(account, { since, limit: MAX_SESSIONS })
17
+ const visits = await store.visitsInRooms(sessions.map((s) => s.room))
18
+ return summarize({ me: account, sessions, visits, now: t, tz })
19
+ }
20
+
21
+ /** People and agents this account has worked with: [{ account, name, kind, lastTogetherAt }], most recent first. */
22
+ export async function collaboratorsOf (store, account, t) {
23
+ return collaborators((await overviewOf(store, account, 'UTC', t)).sessions)
24
+ }
25
+
26
+ export function sessionRoutes ({ store, now, person }) {
27
+ const me = async (req) => `person:${(await person(req)).userId}`
28
+ const zoneOf = (req) => {
29
+ const tz = new URL(req.url, 'http://x').searchParams.get('tz') || 'UTC'
30
+ if (!isTimeZone(tz)) throw new HttpError(400, 'tz must be an IANA time zone, like Europe/London')
31
+ return tz
32
+ }
33
+ const overview = (account, tz) => overviewOf(store, account, tz, now())
34
+
35
+ /** A session I was in, with every visit to it; 404 otherwise, whether or not it exists. */
36
+ async function mine (account, room) {
37
+ if (!ROOM.test(room)) throw new HttpError(404, NO_SESSION)
38
+ const session = await store.sessionByRoom(room)
39
+ const visits = session ? await store.visitsInRooms([room]) : []
40
+ if (!visits.some((v) => v.account === account)) throw new HttpError(404, NO_SESSION)
41
+ return { session, visits }
42
+ }
43
+
44
+ return [
45
+ ['GET', /^\/v1\/me\/sessions$/, async (req) => overview(await me(req), zoneOf(req))],
46
+
47
+ ['GET', /^\/v1\/me\/sessions\/([^/]+)$/, async (req, body, [room]) => {
48
+ const account = await me(req)
49
+ const { session, visits } = await mine(account, room)
50
+ const [s] = summarize({ me: account, sessions: [session], visits, now: now() }).sessions
51
+ return { session: { ...s, visits: myVisits({ me: account, visits }) } }
52
+ }],
53
+
54
+ // The owner's rename shows for everyone, and the relay's name no longer replaces it.
55
+ ['PUT', /^\/v1\/me\/sessions\/([^/]+)$/, async (req, body, [room]) => {
56
+ const account = await me(req)
57
+ const name = cleanSessionName(body.name)
58
+ if (!name) throw new HttpError(400, BAD_SESSION_NAME)
59
+ const { session } = await mine(account, room)
60
+ if (session.ownerAccount !== account) throw new HttpError(403, 'Only the session owner can rename it.')
61
+ const renamed = await store.renameSession(room, name, now())
62
+ return { session: { room, name: renamed.name } }
63
+ }],
64
+
65
+ // The audit trail, for the owner only: every visit, how it came in, why it ended, and
66
+ // what it did. ?format=csv for a spreadsheet; ?from= and ?to= (epoch ms) narrow it.
67
+ ['GET', /^\/v1\/me\/sessions\/([^/]+)\/audit$/, async (req, body, [room]) => {
68
+ const account = await me(req)
69
+ const { session, visits } = await mine(account, room)
70
+ if (session.ownerAccount !== account) throw new HttpError(403, 'Only the session owner can see its audit trail.')
71
+ const q = new URL(req.url, 'http://x').searchParams
72
+ const t = now()
73
+ const num = (k, d) => { const n = Number(q.get(k)); return q.has(k) && Number.isFinite(n) ? n : d }
74
+ const from = num('from', 0)
75
+ const to = num('to', t + 1)
76
+ const inWindow = visits.filter((v) => v.startedAt < to && (v.endedAt == null || v.endedAt >= from))
77
+ const actions = await store.actionsInRoom(room, { from, to, limit: MAX_ACTIONS + 1 })
78
+ const truncated = actions.length > MAX_ACTIONS
79
+ const revoked = new Map()
80
+ for (const acc of new Set(inWindow.filter((v) => v.kind === 'agent').map((v) => v.account))) {
81
+ const agent = await store.agentById(acc.slice('agent:'.length)).catch(() => null)
82
+ if (agent && agent.revokedAt != null) revoked.set(acc, agent.revokedAt)
83
+ }
84
+ const trail = auditTrail({ visits: inWindow, actions: actions.slice(0, MAX_ACTIONS), revoked })
85
+ if (q.get('format') === 'csv') return new Raw(200, auditCsv(trail), 'text/csv; charset=utf-8')
86
+ return { session: { room, name: session.name }, from, to, truncated, visits: trail }
87
+ }],
88
+
89
+ // People and agents you've worked with, for invites (most recent first, at most 30).
90
+ ['GET', /^\/v1\/me\/collaborators$/, async (req) => ({ collaborators: await collaboratorsOf(store, await me(req), now()) })]
91
+ ]
92
+ }
@@ -0,0 +1,132 @@
1
+ // Flat teams inside an org, and who's in each: people and agents with editor or
2
+ // viewer access, and for agents, optional folders.
3
+ import { HttpError, needId, cleanName } from '../http.js'
4
+ import { orgAccess } from '../org-access.js'
5
+ import { accessOf, cleanScopes } from '../team-access.js'
6
+
7
+ const kindOf = (m) => (m.agentId ? 'agent' : 'person')
8
+ const memberView = (tm) => ({ memberId: tm.memberId, access: tm.access, scopes: tm.scopes || [] })
9
+
10
+ export function teamRoutes ({ store, user, policyChanged = null }) {
11
+ const orgFor = async (req, slug) => { const u = await user(req); return { u, ...(await orgAccess(store, u.userId, slug)) } }
12
+
13
+ async function teamIn (a, id) {
14
+ const team = await store.teamById(a.org.id, needId(id, 'team'))
15
+ if (!team) throw new HttpError(404, 'no such team')
16
+ return team
17
+ }
18
+
19
+ // Folders only limit agents; a person in a team always reaches all of it.
20
+ // undefined means "leave the folders as they are".
21
+ function foldersFor (m, raw) {
22
+ if (raw === undefined) return undefined
23
+ const scopes = cleanScopes(raw)
24
+ if (scopes.length && !m.agentId) throw new HttpError(400, 'Folders are only for agents.')
25
+ return scopes
26
+ }
27
+
28
+ return [
29
+ ['GET', /^\/v1\/orgs\/([^/]+)\/teams$/, async (req, body, [slug]) => {
30
+ const a = await orgFor(req, slug)
31
+ const mine = new Map((await store.teamsOfMember(a.me.id)).map((x) => [x.teamId, x.access]))
32
+ // Teams: Read shows every team; everyone sees the teams they're in.
33
+ const teams = (await store.listTeams(a.org.id)).filter((t) => a.can('teams', 'r') || mine.has(t.id))
34
+ return {
35
+ teams: await Promise.all(teams.map(async (t) => ({
36
+ id: t.id,
37
+ name: t.name,
38
+ createdAt: t.createdAt,
39
+ access: mine.get(t.id) || null,
40
+ // The team's git rule (git for everyone: '' sets nothing, off, branches or any).
41
+ git: (await store.teamGithub(t.id)).agentCommits,
42
+ members: a.can('team_members', 'r') || mine.has(t.id)
43
+ ? (await store.listTeamMembers(t.id)).map((m) => ({ memberId: m.memberId, name: m.name, access: m.access, kind: m.kind, scopes: m.scopes || [] }))
44
+ : null
45
+ }))),
46
+ // The people and agents you could add, for those who add to teams.
47
+ people: a.can('team_members', 'c')
48
+ ? (await store.listMembers(a.org.id)).map((m) => ({ memberId: m.id, name: m.name, kind: kindOf(m) }))
49
+ : null
50
+ }
51
+ }],
52
+
53
+ ['POST', /^\/v1\/orgs\/([^/]+)\/teams$/, async (req, body, [slug]) => {
54
+ const a = await orgFor(req, slug)
55
+ a.need('teams', 'c')
56
+ return { team: await store.createTeam({ orgId: a.org.id, name: cleanName(body.name, 60, 'give the team a name') }) }
57
+ }],
58
+
59
+ ['PUT', /^\/v1\/orgs\/([^/]+)\/teams\/([^/]+)$/, async (req, body, [slug, id]) => {
60
+ const a = await orgFor(req, slug)
61
+ a.need('teams', 'u')
62
+ const team = await teamIn(a, id)
63
+ return { team: await store.renameTeam(team.id, cleanName(body.name, 60, 'give the team a name')) }
64
+ }],
65
+
66
+ ['DELETE', /^\/v1\/orgs\/([^/]+)\/teams\/([^/]+)$/, async (req, body, [slug, id]) => {
67
+ const a = await orgFor(req, slug)
68
+ a.need('teams', 'd')
69
+ await store.deleteTeam((await teamIn(a, id)).id)
70
+ if (policyChanged) policyChanged() // its git rule goes with it
71
+ return { ok: true }
72
+ }],
73
+
74
+ ['POST', /^\/v1\/orgs\/([^/]+)\/teams\/([^/]+)\/members$/, async (req, body, [slug, id]) => {
75
+ const a = await orgFor(req, slug)
76
+ a.need('team_members', 'c')
77
+ const team = await teamIn(a, id)
78
+ const access = accessOf(body.access ?? 'viewer')
79
+ const m = await store.memberById(a.org.id, needId(body.memberId, 'member'))
80
+ if (!m) throw new HttpError(404, 'no such member')
81
+ const scopes = foldersFor(m, body.scopes) ?? []
82
+ // addTeamMember upserts; without this, Create alone could restyle someone's
83
+ // access by re-adding them, which is Team membership: Update's job.
84
+ if ((await store.listTeamMembers(team.id)).some((x) => x.memberId === m.id)) throw new HttpError(409, 'That person is already in this team.')
85
+ const added = memberView(await store.addTeamMember({ teamId: team.id, memberId: m.id, access, scopes }))
86
+ if (policyChanged) policyChanged() // the team's git rule now covers their sessions
87
+ return { member: added }
88
+ }],
89
+
90
+ ['PUT', /^\/v1\/orgs\/([^/]+)\/teams\/([^/]+)\/members\/([^/]+)$/, async (req, body, [slug, id, memberId]) => {
91
+ const a = await orgFor(req, slug)
92
+ a.need('team_members', 'u')
93
+ const team = await teamIn(a, id)
94
+ const m = await store.memberById(a.org.id, needId(memberId, 'member'))
95
+ if (!m) throw new HttpError(404, "that person isn't in this team")
96
+ const tm = await store.setTeamAccess(team.id, m.id, accessOf(body.access), foldersFor(m, body.scopes))
97
+ if (!tm) throw new HttpError(404, "that person isn't in this team")
98
+ return { member: memberView(tm) }
99
+ }],
100
+
101
+ // The team's git rule (docs/superpowers/specs/2026-10-17-git-for-everyone.md): whether git is on for
102
+ // everyone in the sessions its people own in the org's workspaces, and what agents may push there.
103
+ // '' sets nothing; off, branches or any is a ceiling for the workspaces' and sessions' own choices.
104
+ ['GET', /^\/v1\/orgs\/([^/]+)\/teams\/([^/]+)\/github$/, async (req, body, [slug, id]) => {
105
+ const a = await orgFor(req, slug)
106
+ const team = await teamIn(a, id)
107
+ const mine = (await store.teamsOfMember(a.me.id)).some((x) => x.teamId === team.id)
108
+ if (!a.can('teams', 'r') && !mine) throw new HttpError(404, 'no such team')
109
+ return store.teamGithub(team.id)
110
+ }],
111
+ ['PUT', /^\/v1\/orgs\/([^/]+)\/teams\/([^/]+)\/github$/, async (req, body, [slug, id]) => {
112
+ const a = await orgFor(req, slug)
113
+ a.need('teams', 'u')
114
+ const team = await teamIn(a, id)
115
+ if (!('agentCommits' in body)) throw new HttpError(400, 'Give agentCommits: "" (the team sets nothing), off, branches or any.')
116
+ const v = body.agentCommits === null ? '' : body.agentCommits
117
+ if (!['', 'off', 'branches', 'any'].includes(v)) throw new HttpError(400, 'agentCommits is "" (the team sets nothing), off, branches or any.')
118
+ await store.setTeamGithub(team.id, { agentCommits: v })
119
+ if (policyChanged) policyChanged()
120
+ return store.teamGithub(team.id)
121
+ }],
122
+
123
+ ['DELETE', /^\/v1\/orgs\/([^/]+)\/teams\/([^/]+)\/members\/([^/]+)$/, async (req, body, [slug, id, memberId]) => {
124
+ const a = await orgFor(req, slug)
125
+ a.need('team_members', 'd')
126
+ const team = await teamIn(a, id)
127
+ if (!await store.removeTeamMember(team.id, needId(memberId, 'member'))) throw new HttpError(404, "that person isn't in this team")
128
+ if (policyChanged) policyChanged()
129
+ return { ok: true }
130
+ }]
131
+ ]
132
+ }