heyquilt 0.0.1 → 0.3.106
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.
- package/LICENSE +85 -0
- package/README.md +633 -2
- package/RELEASES.md +862 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +849 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +591 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +187 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +177 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +110 -0
- package/src/ai-vm.js +522 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +136 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +133 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1219 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +545 -0
- package/src/api/relay-machines.js +92 -0
- package/src/api/releases.js +87 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +252 -0
- package/src/api/routes/ai-vm.js +495 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +179 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/secrets.js +448 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +232 -0
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +654 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +875 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +375 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +128 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +248 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +97 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +924 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +45 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1258 -0
- package/src/merge3.js +240 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +183 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +134 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +214 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +166 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1691 -0
- package/src/relay-metrics.js +140 -0
- package/src/relay-outbound.js +1273 -0
- package/src/relay-sync.js +716 -0
- package/src/relay-turn-sync.js +506 -0
- package/src/relay-upstream.js +804 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +138 -0
- package/src/report.js +129 -0
- package/src/runner.js +307 -0
- package/src/same-edit.js +18 -0
- package/src/secret-import.js +55 -0
- package/src/secret-names.js +26 -0
- package/src/secret-run.js +80 -0
- package/src/secrets-client.js +56 -0
- package/src/server.js +4214 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7446 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +197 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +145 -0
- package/src/turn-sync.js +212 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2494 -0
- package/src/ui/app.js +546 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +108 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +433 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +150 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1322 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +365 -0
- package/src/ui/loom.js +786 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/rail-model.js +75 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/secret-rules.js +255 -0
- package/src/ui/secrets-view.js +162 -0
- package/src/ui/secrets.js +283 -0
- package/src/ui/session.js +3292 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +281 -0
- package/src/ui/workspaces.js +547 -0
- package/src/ui-server.js +1381 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +192 -0
- package/src/vault/crypto.js +96 -0
- package/src/vault/kms.js +61 -0
- package/src/vault/main.js +33 -0
- package/src/vault/server.js +279 -0
- package/src/vault/sigv4.js +38 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// Agents in workspaces: where an agent works (its placement, set by its owner), a
|
|
2
|
+
// workspace's say over an agent placed there (its override), workspace agent invites,
|
|
3
|
+
// a session owner keeping an agent out of one session, and an agent's own webhook for
|
|
4
|
+
// workspace events. Behind the QUILT_WORKSPACES flag: off, every route answers 404.
|
|
5
|
+
import { HttpError, needId } from '../http.js'
|
|
6
|
+
import { newToken, hashToken } from '../tokens.js'
|
|
7
|
+
import { orgAccess } from '../org-access.js'
|
|
8
|
+
import { cleanAccess, stillInOrg } from '../workspace-access.js'
|
|
9
|
+
import { workspaceReach } from '../workspace-reach.js'
|
|
10
|
+
import { cleanPlacement, placementIn, agentsJoiningSession, sessionAgents, sameOwnerAgent, SESSIONS } from '../agent-placement.js'
|
|
11
|
+
import { AGENT_INVITE_TTL_MS, inviteStatus } from './agent-invites.js'
|
|
12
|
+
import crypto from 'node:crypto'
|
|
13
|
+
import { parseWebhookUrl, newSecret, publicWebhookHost, deliverWebhook, MIN_SECRET, MAX_SECRET } from '../../webhooks.js'
|
|
14
|
+
import { parseInvite, buildInvite } from '../../ui/invite.js'
|
|
15
|
+
import { isHostedRelay, HOSTED_RELAY } from '../../settings.js'
|
|
16
|
+
|
|
17
|
+
const ROOM = /^[A-Za-z0-9_-]{1,64}$/
|
|
18
|
+
export const AGENT_WEBHOOK_EVENTS = ['session.started']
|
|
19
|
+
/** Why someone else's agent in a workspace is never invited to every session there. */
|
|
20
|
+
export const FOREIGN_JOINS = 'Only its owner can have an agent invited to every session.'
|
|
21
|
+
|
|
22
|
+
/** A placement as the routes answer it; an agent with none is 'manual' (it works only where it is added). */
|
|
23
|
+
export function placementView (agentId, p) {
|
|
24
|
+
if (!p) return { agentId, reach: 'manual', workspaceIds: [], sessions: 'invited', access: 'edit', scopes: [], updatedAt: null }
|
|
25
|
+
return { agentId, reach: p.reach, workspaceIds: [...(p.workspaceIds || [])], sessions: p.sessions, access: p.access, scopes: [...(p.scopes || [])], updatedAt: p.updatedAt ?? null }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A member agent's `sessions`: 'all' or 'invited', and only for an agent. Undefined keeps what the member has. */
|
|
29
|
+
export function cleanMemberSessions (value, kind) {
|
|
30
|
+
if (value === undefined) return undefined
|
|
31
|
+
if (kind !== 'agent') throw new HttpError(400, 'Only an agent joins sessions by itself.')
|
|
32
|
+
if (!SESSIONS.includes(value)) throw new HttpError(400, 'Sessions is all or invited.')
|
|
33
|
+
return value
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The agents in `ws` for its page: member agents (via 'member', managed by the workspace),
|
|
38
|
+
* then agents of the workspace's owner placed there (via 'placed' or 'global', managed by
|
|
39
|
+
* the owner or the org), with the workspace's override applied. Agents the workspace kept
|
|
40
|
+
* out are listed (excluded: true) only for an admin, so they can be let back in. `foreign`
|
|
41
|
+
* marks someone else's agent added here: it joins only when invited, whatever its row says.
|
|
42
|
+
*/
|
|
43
|
+
export async function workspaceAgents (store, ws, { admin = false } = {}) {
|
|
44
|
+
const out = []
|
|
45
|
+
const seen = new Set()
|
|
46
|
+
for (const m of await store.listWorkspaceMembers(ws.id)) {
|
|
47
|
+
if (!m.account.startsWith('agent:')) continue
|
|
48
|
+
const agentId = m.account.slice(6)
|
|
49
|
+
const agent = await store.agentById(agentId)
|
|
50
|
+
if (!agent || agent.revokedAt || !(await stillInOrg(store, ws, m.account, null))) continue
|
|
51
|
+
seen.add(agentId)
|
|
52
|
+
const foreign = !(await sameOwnerAgent(store, ws, agentId))
|
|
53
|
+
out.push({ account: m.account, agentId, name: agent.name, provider: agent.provider, via: 'member', access: m.access, sessions: foreign ? 'invited' : (m.sessions || 'invited'), managedBy: 'workspace', excluded: false, foreign })
|
|
54
|
+
}
|
|
55
|
+
const own = ws.orgId ? await store.listOrgAgents(ws.orgId) : ws.ownerUserId ? await store.listPersonalAgents(ws.ownerUserId) : []
|
|
56
|
+
const placed = []
|
|
57
|
+
for (const agent of own) {
|
|
58
|
+
if (seen.has(agent.id)) continue
|
|
59
|
+
const p = await placementIn(store, ws, agent.id)
|
|
60
|
+
if (!p) continue
|
|
61
|
+
const override = await store.workspaceAgentOverride(ws.id, agent.id)
|
|
62
|
+
const excluded = !!override?.excluded
|
|
63
|
+
if (excluded && !admin) continue
|
|
64
|
+
placed.push({ account: `agent:${agent.id}`, agentId: agent.id, name: agent.name, provider: agent.provider, via: p.reach === 'all' ? 'global' : 'placed', access: p.access, sessions: override?.sessions ?? p.sessions, managedBy: ws.orgId ? 'org' : 'owner', excluded, foreign: false })
|
|
65
|
+
}
|
|
66
|
+
placed.sort((a, b) => a.name.localeCompare(b.name))
|
|
67
|
+
return [...out, ...placed]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Every workspace an agent can find by its placement: its owner's personal ones, or its org's. */
|
|
71
|
+
export async function placementCandidates (store, agent) {
|
|
72
|
+
if (!agent) return []
|
|
73
|
+
if (agent.orgId) return store.listWorkspacesOfOrg(agent.orgId)
|
|
74
|
+
return agent.ownerUserId ? store.listWorkspacesOwnedBy(agent.ownerUserId) : []
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A relay address as join links carry it (ws:// or wss://, no trailing slash). */
|
|
78
|
+
const relayForm = (url) => String(url || '').replace(/\/+$/, '').replace(/^http(s?):\/\//, 'ws$1://')
|
|
79
|
+
|
|
80
|
+
export function workspaceAgentRoutes (ctx) {
|
|
81
|
+
const { store, person, now, apiUrl, limitSend, limitAnnounce = () => {}, log = () => {}, workspaces = false } = ctx
|
|
82
|
+
// The session-started hand-off: how webhooks are sent (tests record them), how host names
|
|
83
|
+
// resolve, and whether a receiver on this computer is allowed (tests only).
|
|
84
|
+
const { relayUrl = HOSTED_RELAY, webhookFetch = globalThis.fetch, webhookLookup, allowLocalWebhooks = false, trackDelivery = (p) => p } = ctx
|
|
85
|
+
const ownRelay = relayForm(relayUrl)
|
|
86
|
+
const { caller, reach } = workspaceReach(ctx)
|
|
87
|
+
const gated = (fn) => (...args) => {
|
|
88
|
+
if (!workspaces) throw new HttpError(404, 'not found')
|
|
89
|
+
return fn(...args)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A placement body, checked, with every listed workspace one of `owns` (the owner's own). */
|
|
93
|
+
async function placementFrom (body, owns) {
|
|
94
|
+
const p = cleanPlacement(body)
|
|
95
|
+
for (const id of p.workspaceIds) {
|
|
96
|
+
const ws = await store.workspaceById(id)
|
|
97
|
+
if (!ws || !owns(ws)) throw new HttpError(400, 'An agent can only be placed in its owner\'s own workspaces.')
|
|
98
|
+
}
|
|
99
|
+
return p
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function myAgent (req, id) {
|
|
103
|
+
const u = await person(req)
|
|
104
|
+
const agent = await store.agentById(needId(id, 'agent'))
|
|
105
|
+
// Someone else's agent, an org's, or a revoked one: the same answer as a missing one.
|
|
106
|
+
if (!agent || agent.ownerUserId !== u.userId || agent.revokedAt) throw new HttpError(404, 'no such agent')
|
|
107
|
+
return { u, agent }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function orgFor (req, slug) {
|
|
111
|
+
const u = await person(req)
|
|
112
|
+
return { u, ...(await orgAccess(store, u.userId, slug)) }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function orgAgent (a, id) {
|
|
116
|
+
const agent = await store.agentById(needId(id, 'agent'))
|
|
117
|
+
if (!agent || agent.orgId !== a.org.id || agent.revokedAt) throw new HttpError(404, 'no such agent')
|
|
118
|
+
return agent
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The room's session when the caller owns it (as the relay reports it). */
|
|
122
|
+
async function ownRoom (req, room) {
|
|
123
|
+
const me = await caller(req)
|
|
124
|
+
if (!ROOM.test(room)) throw new HttpError(404, 'no such session')
|
|
125
|
+
const session = await store.sessionByRoom(room)
|
|
126
|
+
if (!session) throw new HttpError(404, 'no such session')
|
|
127
|
+
if (!session.ownerAccount || session.ownerAccount !== me.account) throw new HttpError(403, 'Only the session owner can do that.')
|
|
128
|
+
return { me, session }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The room's session when the caller owns it, and the agent to keep out. */
|
|
132
|
+
async function ownSession (req, room, agentId) {
|
|
133
|
+
const { me, session } = await ownRoom(req, room)
|
|
134
|
+
const agent = await store.agentById(needId(agentId, 'agent'))
|
|
135
|
+
if (!agent) throw new HttpError(404, 'no such agent')
|
|
136
|
+
return { me, session, agent }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function meAsAgent (req) {
|
|
140
|
+
const me = await caller(req)
|
|
141
|
+
if (!me.account.startsWith('agent:')) throw new HttpError(403, 'Only an agent has a webhook here.')
|
|
142
|
+
return me.agent || await store.agentById(me.account.slice(6))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Sends one agent its session.started event, after checking its webhook still points at the
|
|
147
|
+
* public internet as it resolves now (the URL was only checked as written when it was set).
|
|
148
|
+
* Never throws: a skipped or failed send is logged, without the link.
|
|
149
|
+
*/
|
|
150
|
+
async function sendSessionStarted (agentId, hook, payload) {
|
|
151
|
+
try {
|
|
152
|
+
let url
|
|
153
|
+
try { url = parseWebhookUrl(hook.url, { allowLocal: allowLocalWebhooks }) } catch (e) {
|
|
154
|
+
log(`session.started to agent ${agentId} skipped: ${hook.url}: ${e.message}`)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
if (!allowLocalWebhooks) {
|
|
158
|
+
const host = await publicWebhookHost(url, webhookLookup ? { lookup: webhookLookup } : {})
|
|
159
|
+
if (!host.ok) { log(`session.started to agent ${agentId} skipped: ${host.reason}`); return }
|
|
160
|
+
}
|
|
161
|
+
await deliverWebhook({ url, secret: hook.secret }, payload, { fetch: webhookFetch, now, log })
|
|
162
|
+
} catch (err) {
|
|
163
|
+
log(`session.started to agent ${agentId} failed: ${err?.message || err}`)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const routes = [
|
|
168
|
+
['GET', /^\/v1\/me\/agents\/([^/]+)\/placement$/, async (req, body, [id]) => {
|
|
169
|
+
const { agent } = await myAgent(req, id)
|
|
170
|
+
return { placement: placementView(agent.id, await store.agentPlacement(agent.id)) }
|
|
171
|
+
}],
|
|
172
|
+
|
|
173
|
+
['PUT', /^\/v1\/me\/agents\/([^/]+)\/placement$/, async (req, body, [id]) => {
|
|
174
|
+
const { u, agent } = await myAgent(req, id)
|
|
175
|
+
// A personal agent's workspaces are its owner's personal ones, never an org's.
|
|
176
|
+
const p = await placementFrom(body, (ws) => !ws.orgId && ws.ownerUserId === u.userId)
|
|
177
|
+
return { placement: placementView(agent.id, await store.putAgentPlacement({ agentId: agent.id, ...p, updatedBy: `person:${u.userId}` })) }
|
|
178
|
+
}],
|
|
179
|
+
|
|
180
|
+
['GET', /^\/v1\/orgs\/([^/]+)\/agents$/, async (req, body, [slug]) => {
|
|
181
|
+
const a = await orgFor(req, slug)
|
|
182
|
+
a.need('agents', 'r')
|
|
183
|
+
const agents = await store.listOrgAgents(a.org.id)
|
|
184
|
+
const placements = new Map((await store.listAgentPlacements(agents.map((x) => x.id))).map((p) => [p.agentId, p]))
|
|
185
|
+
return { agents: agents.map((x) => ({ id: x.id, name: x.name, provider: x.provider, type: x.type, hosted: !x.publicKey, placement: placementView(x.id, placements.get(x.id)) })) }
|
|
186
|
+
}],
|
|
187
|
+
|
|
188
|
+
['PUT', /^\/v1\/orgs\/([^/]+)\/agents\/([^/]+)\/placement$/, async (req, body, [slug, id]) => {
|
|
189
|
+
const a = await orgFor(req, slug)
|
|
190
|
+
a.need('agents', 'u')
|
|
191
|
+
// Placing an agent puts it in the org's workspaces, so it takes Workspaces: Update too.
|
|
192
|
+
a.need('workspaces', 'u')
|
|
193
|
+
const agent = await orgAgent(a, id)
|
|
194
|
+
const p = await placementFrom(body, (ws) => ws.orgId === a.org.id)
|
|
195
|
+
return { placement: placementView(agent.id, await store.putAgentPlacement({ agentId: agent.id, ...p, updatedBy: `person:${a.u.userId}` })) }
|
|
196
|
+
}],
|
|
197
|
+
|
|
198
|
+
// Only for an agent the workspace's owner placed there: an agent added by hand is
|
|
199
|
+
// managed through its member row instead.
|
|
200
|
+
['PUT', /^\/v1\/workspaces\/([^/]+)\/agents\/([^/]+)$/, async (req, body, [id, agentId]) => {
|
|
201
|
+
const r = await reach(req, id)
|
|
202
|
+
r.needAdmin()
|
|
203
|
+
if (!(await placementIn(store, r.ws, agentId))) throw new HttpError(404, 'that agent is not placed in this workspace')
|
|
204
|
+
const b = body && typeof body === 'object' ? body : {}
|
|
205
|
+
if (b.sessions !== undefined && b.sessions !== null && !SESSIONS.includes(b.sessions)) throw new HttpError(400, 'Sessions is all, invited or null.')
|
|
206
|
+
if (b.excluded !== undefined && typeof b.excluded !== 'boolean') throw new HttpError(400, 'excluded is true or false.')
|
|
207
|
+
const old = await store.workspaceAgentOverride(r.ws.id, agentId)
|
|
208
|
+
const override = await store.putWorkspaceAgentOverride({
|
|
209
|
+
workspaceId: r.ws.id,
|
|
210
|
+
agentId,
|
|
211
|
+
sessions: b.sessions !== undefined ? b.sessions : (old?.sessions ?? null),
|
|
212
|
+
excluded: b.excluded !== undefined ? b.excluded : !!old?.excluded
|
|
213
|
+
})
|
|
214
|
+
return { override }
|
|
215
|
+
}],
|
|
216
|
+
|
|
217
|
+
['DELETE', /^\/v1\/workspaces\/([^/]+)\/agents\/([^/]+)$/, async (req, body, [id, agentId]) => {
|
|
218
|
+
const r = await reach(req, id)
|
|
219
|
+
r.needAdmin()
|
|
220
|
+
await store.deleteWorkspaceAgentOverride(r.ws.id, needId(agentId, 'agent'))
|
|
221
|
+
return { ok: true }
|
|
222
|
+
}],
|
|
223
|
+
|
|
224
|
+
// A one-time link (good for an hour) for a new agent of the workspace's owner, which
|
|
225
|
+
// joins as a member of this workspace. An org's needs Agents: Create too.
|
|
226
|
+
['POST', /^\/v1\/workspaces\/([^/]+)\/agent-invites$/, async (req, body, [id]) => {
|
|
227
|
+
const r = await reach(req, id)
|
|
228
|
+
r.needAdmin()
|
|
229
|
+
if (!r.me.userId) throw new HttpError(403, 'agents do not invite agents')
|
|
230
|
+
const workspaceAccess = cleanAccess(body.access ?? 'edit')
|
|
231
|
+
const workspaceSessions = body.sessions ?? 'invited'
|
|
232
|
+
if (!SESSIONS.includes(workspaceSessions)) throw new HttpError(400, 'Sessions is all or invited.')
|
|
233
|
+
let home
|
|
234
|
+
if (r.ws.orgId) {
|
|
235
|
+
const a = await orgAccess(store, r.me.userId, (await store.orgById(r.ws.orgId)).slug)
|
|
236
|
+
a.need('agents', 'c')
|
|
237
|
+
home = { orgId: a.org.id }
|
|
238
|
+
} else home = { ownerUserId: r.ws.ownerUserId }
|
|
239
|
+
limitSend(r.me.userId)
|
|
240
|
+
const token = newToken('qj_')
|
|
241
|
+
const invite = await store.createAgentInvite({ tokenHash: hashToken(token), createdBy: r.me.userId, expiresAt: now() + AGENT_INVITE_TTL_MS, ...home, workspaceId: r.ws.id, workspaceAccess, workspaceSessions })
|
|
242
|
+
return {
|
|
243
|
+
invite: { id: invite.id, kind: invite.orgId ? 'org' : 'personal', status: inviteStatus(invite, now()), workspaceId: invite.workspaceId, workspaceAccess: invite.workspaceAccess, workspaceSessions: invite.workspaceSessions, createdAt: invite.createdAt, expiresAt: invite.expiresAt },
|
|
244
|
+
link: `${apiUrl}/v1/join/${token}`
|
|
245
|
+
}
|
|
246
|
+
}],
|
|
247
|
+
|
|
248
|
+
// The session's agents for its People: the ones its workspace invites (and why), and the
|
|
249
|
+
// ones its owner keeps out (Don't invite), so the owner can change either.
|
|
250
|
+
['GET', /^\/v1\/sessions\/([^/]+)\/agents$/, async (req, body, [room]) => {
|
|
251
|
+
await ownRoom(req, room)
|
|
252
|
+
return { agents: await sessionAgents(store, room) }
|
|
253
|
+
}],
|
|
254
|
+
|
|
255
|
+
// The agents its owner keeps out of this session, so the app can offer to let them back in.
|
|
256
|
+
['GET', /^\/v1\/sessions\/([^/]+)\/agents\/excluded$/, async (req, body, [room]) => {
|
|
257
|
+
await ownRoom(req, room)
|
|
258
|
+
const agents = []
|
|
259
|
+
for (const e of await store.listSessionAgentExclusions(room)) agents.push({ agentId: e.agentId, name: (await store.agentById(e.agentId))?.name || '' })
|
|
260
|
+
return { agents }
|
|
261
|
+
}],
|
|
262
|
+
|
|
263
|
+
['PUT', /^\/v1\/sessions\/([^/]+)\/agents\/([^/]+)\/exclude$/, async (req, body, [room, agentId]) => {
|
|
264
|
+
const { me, agent } = await ownSession(req, room, agentId)
|
|
265
|
+
await store.addSessionAgentExclusion({ room, agentId: agent.id, excludedBy: me.account })
|
|
266
|
+
return { ok: true }
|
|
267
|
+
}],
|
|
268
|
+
|
|
269
|
+
['DELETE', /^\/v1\/sessions\/([^/]+)\/agents\/([^/]+)\/exclude$/, async (req, body, [room, agentId]) => {
|
|
270
|
+
const { agent } = await ownSession(req, room, agentId)
|
|
271
|
+
await store.removeSessionAgentExclusion(room, agent.id)
|
|
272
|
+
return { ok: true }
|
|
273
|
+
}],
|
|
274
|
+
|
|
275
|
+
// The session's owner, as soon as it has started a session in this workspace, hands over
|
|
276
|
+
// its join link; every agent that joins the session by itself and has a webhook is sent it
|
|
277
|
+
// at once. The link is used for these sends and never kept. 409 until the relay has said
|
|
278
|
+
// who owns the session (the app tries again shortly).
|
|
279
|
+
['POST', /^\/v1\/workspaces\/([^/]+)\/sessions\/([^/]+)\/started$/, async (req, body, [id, room]) => {
|
|
280
|
+
const r = await reach(req, id)
|
|
281
|
+
if (!ROOM.test(room)) throw new HttpError(404, 'that session is not in this workspace')
|
|
282
|
+
const session = await store.sessionByRoom(room)
|
|
283
|
+
if (!session || session.workspaceId !== r.ws.id) throw new HttpError(404, 'that session is not in this workspace')
|
|
284
|
+
if (session.workspaceLinkedBy !== r.me.account) throw new HttpError(403, 'Only the session owner can do that.')
|
|
285
|
+
if (!session.ownerAccount) throw new HttpError(409, 'Quilt has not heard who owns this session yet; try again in a moment.')
|
|
286
|
+
if (session.ownerAccount !== r.me.account) throw new HttpError(403, 'Only the session owner can do that.')
|
|
287
|
+
let invite
|
|
288
|
+
try { invite = parseInvite(String(body.link || ''), { allowRelay: (s) => isHostedRelay(s) || relayForm(s) === ownRelay }) } catch { throw new HttpError(400, 'link must be this session\'s Quilt invite link') }
|
|
289
|
+
if (invite.room !== room || !invite.secret) throw new HttpError(400, 'link must be this session\'s Quilt invite link')
|
|
290
|
+
// `agents` (optional): only these of the agents it invites, e.g. one invited again.
|
|
291
|
+
const only = body.agents === undefined ? null : body.agents
|
|
292
|
+
if (only !== null && (!Array.isArray(only) || only.length > 100 || only.some((x) => typeof x !== 'string'))) throw new HttpError(400, 'agents must be a list of agent ids.')
|
|
293
|
+
limitAnnounce(room)
|
|
294
|
+
// Sent as Quilt writes it, whatever surrounded it in the request.
|
|
295
|
+
const link = buildInvite({ server: invite.relay || HOSTED_RELAY, room, secret: invite.secret }, isHostedRelay)
|
|
296
|
+
const by = r.me.userId ? ((await store.profile(r.me.userId))?.name || '') : (r.me.agent?.name || '')
|
|
297
|
+
const notified = []
|
|
298
|
+
const withoutWebhook = []
|
|
299
|
+
// An agent that started the session is in it already: it isn't sent its own link.
|
|
300
|
+
const starter = session.ownerAccount.startsWith('agent:') ? session.ownerAccount.slice(6) : null
|
|
301
|
+
for (const { agentId, via } of await agentsJoiningSession(store, room)) {
|
|
302
|
+
if (agentId === starter || (only && !only.includes(agentId))) continue
|
|
303
|
+
const hook = await store.agentWebhook(agentId)
|
|
304
|
+
if (!hook) { withoutWebhook.push(agentId); continue }
|
|
305
|
+
notified.push(agentId)
|
|
306
|
+
const payload = { event: 'session.started', id: crypto.randomUUID(), ts: now(), workspace: { id: r.ws.id, name: r.ws.name }, room, session: room, name: session.name || '', link, by, via }
|
|
307
|
+
trackDelivery(sendSessionStarted(agentId, hook, payload))
|
|
308
|
+
}
|
|
309
|
+
return { notified, withoutWebhook }
|
|
310
|
+
}],
|
|
311
|
+
|
|
312
|
+
// The agent's own webhook for workspace events. The secret signs deliveries: the agent's
|
|
313
|
+
// own (so one webhook, set from a session's relay, signs the same everywhere), or a new one
|
|
314
|
+
// on every PUT without, answered once.
|
|
315
|
+
['PUT', /^\/v1\/agents\/me\/webhook$/, async (req, body) => {
|
|
316
|
+
const agent = await meAsAgent(req)
|
|
317
|
+
let url
|
|
318
|
+
try { url = parseWebhookUrl(body.url, { allowLocal: allowLocalWebhooks }) } catch (e) { throw new HttpError(400, e.message) }
|
|
319
|
+
const given = body.secret == null ? '' : String(body.secret)
|
|
320
|
+
if (given && (given.length < MIN_SECRET || given.length > MAX_SECRET)) throw new HttpError(400, `secret must be ${MIN_SECRET} to ${MAX_SECRET} characters (or leave it out and Quilt makes one).`)
|
|
321
|
+
const hook = await store.putAgentWebhook({ agentId: agent.id, url, secret: given || newSecret() })
|
|
322
|
+
return { url: hook.url, secret: hook.secret, events: [...AGENT_WEBHOOK_EVENTS] }
|
|
323
|
+
}],
|
|
324
|
+
|
|
325
|
+
['DELETE', /^\/v1\/agents\/me\/webhook$/, async (req) => {
|
|
326
|
+
const agent = await meAsAgent(req)
|
|
327
|
+
await store.deleteAgentWebhook(agent.id)
|
|
328
|
+
return { ok: true }
|
|
329
|
+
}]
|
|
330
|
+
]
|
|
331
|
+
return routes.map(([method, pattern, fn, ...options]) => [method, pattern, gated(fn), ...options])
|
|
332
|
+
}
|