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